From ec50db334b43d47b3c520ac7031792295939fc53 Mon Sep 17 00:00:00 2001
From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Date: Thu, 18 Jun 2026 11:48:01 +0200
Subject: [PATCH 001/112] fix(opencode): pass configured headers to Copilot
models (#32815)
---
packages/opencode/src/plugin/github-copilot/copilot.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/packages/opencode/src/plugin/github-copilot/copilot.ts b/packages/opencode/src/plugin/github-copilot/copilot.ts
index ff7f867ab7d..9c744db89b2 100644
--- a/packages/opencode/src/plugin/github-copilot/copilot.ts
+++ b/packages/opencode/src/plugin/github-copilot/copilot.ts
@@ -70,6 +70,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise {
return CopilotModels.get(
base(auth.enterpriseUrl),
{
+ ...(provider.options?.headers as Record | undefined),
Authorization: `Bearer ${auth.refresh}`,
"User-Agent": `opencode/${InstallationVersion}`,
"X-GitHub-Api-Version": API_VERSION,
From 62c746f2e8b4bf325fbdf67bdb3246f043609850 Mon Sep 17 00:00:00 2001
From: Frank
Date: Thu, 18 Jun 2026 14:26:49 +0200
Subject: [PATCH 002/112] zen: budget
---
packages/console/app/src/routes/zen/util/handler.ts | 3 ---
.../app/src/routes/zen/util/providerBudgetTracker.ts | 11 +++++++++--
2 files changed, 9 insertions(+), 5 deletions(-)
diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts
index 18aeedd7887..e34c3e750b8 100644
--- a/packages/console/app/src/routes/zen/util/handler.ts
+++ b/packages/console/app/src/routes/zen/util/handler.ts
@@ -157,9 +157,6 @@ export async function handler(
logger.metric({
provider: providerInfo.id,
"provider.model": providerInfo.model,
- ...(providerBudgetUsage?.[providerInfo.id]
- ? { "provider.budget_usage": providerBudgetUsage?.[providerInfo.id] }
- : {}),
})
const startTimestamp = Date.now()
diff --git a/packages/console/app/src/routes/zen/util/providerBudgetTracker.ts b/packages/console/app/src/routes/zen/util/providerBudgetTracker.ts
index 83cfd70469a..c8b5eb211c0 100644
--- a/packages/console/app/src/routes/zen/util/providerBudgetTracker.ts
+++ b/packages/console/app/src/routes/zen/util/providerBudgetTracker.ts
@@ -1,5 +1,6 @@
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
import { buildRateLimitKey, getRedis } from "./redis"
+import { logger } from "./logger"
export function createProviderBudgetTracker(
providers: {
@@ -22,13 +23,15 @@ export function createProviderBudgetTracker(
const keys = Object.fromEntries(
tracked.map((provider) => [provider.id, buildRateLimitKey("provider-budget", provider.id, interval)]),
)
+ let budgetUsage: Record = {}
return {
check: async () => {
- const ids = tracked.filter((provider) => provider.budgetMode === "fill").map((provider) => provider.id)
+ const ids = tracked.map((provider) => provider.id)
if (ids.length === 0) return {}
const values = await redis.mget<(string | number | null)[]>(ids.map((id) => keys[id]))
- return Object.fromEntries(ids.map((id, index) => [id, Number(values[index] ?? 0)]))
+ budgetUsage = Object.fromEntries(ids.map((id, index) => [id, Number(values[index] ?? 0)]))
+ return budgetUsage
},
track: async (provider: string, costInCent: number) => {
const config = tracked.find((item) => item.id === provider)
@@ -40,6 +43,10 @@ export function createProviderBudgetTracker(
pipeline.incrby(keys[provider], cost)
pipeline.expire(keys[provider], 120)
await pipeline.exec()
+ logger.metric({
+ "provider.budget_usage": budgetUsage[provider] + cost,
+ "model.budget_usage": cost,
+ })
},
}
}
From 2892e97c578183302fe0fbc200ab05c9684c821a Mon Sep 17 00:00:00 2001
From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Date: Thu, 18 Jun 2026 14:54:26 +0200
Subject: [PATCH 003/112] fix(tui): gate background shortcut by capability
(#32837)
---
.../instance/httpapi/groups/experimental.ts | 15 +++++++
.../instance/httpapi/handlers/experimental.ts | 5 +++
.../test/server/httpapi-exercise/index.ts | 6 +++
packages/sdk/js/src/v2/gen/sdk.gen.ts | 43 +++++++++++++++++++
packages/sdk/js/src/v2/gen/types.gen.ts | 34 +++++++++++++++
packages/tui/src/context/sync.tsx | 27 ++++++++++--
packages/tui/src/routes/session/index.tsx | 37 +++++++++-------
packages/tui/test/fixture/tui-sdk.ts | 1 +
8 files changed, 148 insertions(+), 20 deletions(-)
diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts
index 378e0b72174..52c714a5ae6 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts
@@ -25,6 +25,10 @@ const ConsoleStateResponse = Schema.Struct({
switchableOrgCount: NonNegativeInt,
}).annotate({ identifier: "ConsoleState" })
+const CapabilitiesResponse = Schema.Struct({
+ backgroundSubagents: Schema.Boolean,
+}).annotate({ identifier: "ExperimentalCapabilities" })
+
const ConsoleOrgOption = Schema.Struct({
accountID: Schema.String,
accountEmail: Schema.String,
@@ -84,6 +88,7 @@ export const SessionListQuery = Schema.Struct({
})
export const ExperimentalPaths = {
+ capabilities: "/experimental/capabilities",
console: "/experimental/console",
consoleOrgs: "/experimental/console/orgs",
consoleSwitch: "/experimental/console/switch",
@@ -100,6 +105,16 @@ export const ExperimentalApi = HttpApi.make("experimental")
.add(
HttpApiGroup.make("experimental")
.add(
+ HttpApiEndpoint.get("capabilities", ExperimentalPaths.capabilities, {
+ query: WorkspaceRoutingQuery,
+ success: described(CapabilitiesResponse, "Experimental capabilities"),
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "experimental.capabilities.get",
+ summary: "Get experimental capabilities",
+ description: "Get experimental features enabled on the OpenCode server.",
+ }),
+ ),
HttpApiEndpoint.get("console", ExperimentalPaths.console, {
query: WorkspaceRoutingQuery,
success: described(ConsoleStateResponse, "Active Console provider metadata"),
diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts
index 57d6464d90c..caed5440052 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts
@@ -36,6 +36,10 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
const background = yield* BackgroundJob.Service
const flags = yield* RuntimeFlags.Service
+ const capabilities = Effect.fn("ExperimentalHttpApi.capabilities")(function* () {
+ return { backgroundSubagents: flags.experimentalBackgroundSubagents }
+ })
+
const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () {
const [state, groups] = yield* Effect.all(
[
@@ -171,6 +175,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
})
return handlers
+ .handle("capabilities", capabilities)
.handle("console", getConsole)
.handle("consoleOrgs", listConsoleOrgs)
.handle("consoleSwitch", switchConsole)
diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts
index 3860d742ddc..e8fdadd8ed2 100644
--- a/packages/opencode/test/server/httpapi-exercise/index.ts
+++ b/packages/opencode/test/server/httpapi-exercise/index.ts
@@ -578,6 +578,12 @@ const scenarios: Scenario[] = [
.get("/experimental/session", "experimental.session.list")
.at((ctx) => ({ path: "/experimental/session?roots=false&archived=false", headers: ctx.headers() }))
.json(200, array),
+ http.protected
+ .get("/experimental/capabilities", "experimental.capabilities.get")
+ .json(200, (body) => {
+ check(typeof body === "object" && body !== null, "capabilities should be an object")
+ check("backgroundSubagents" in body, "capabilities should report background subagents")
+ }),
http.protected
.post("/experimental/session/{sessionID}/background", "experimental.session.background")
.mutating()
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index 0b72e620ae5..7c1c9108d95 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -29,6 +29,8 @@ import type {
EventTuiPromptAppend,
EventTuiSessionSelect,
EventTuiToastShow,
+ ExperimentalCapabilitiesGetErrors,
+ ExperimentalCapabilitiesGetResponses,
ExperimentalConsoleGetErrors,
ExperimentalConsoleGetResponses,
ExperimentalConsoleListOrgsErrors,
@@ -627,6 +629,42 @@ export class ControlPlane extends HeyApiClient {
}
}
+export class Capabilities extends HeyApiClient {
+ /**
+ * Get experimental capabilities
+ *
+ * Get experimental features enabled on the OpenCode server.
+ */
+ public get(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get<
+ ExperimentalCapabilitiesGetResponses,
+ ExperimentalCapabilitiesGetErrors,
+ ThrowOnError
+ >({
+ url: "/experimental/capabilities",
+ ...options,
+ ...params,
+ })
+ }
+}
+
export class Console extends HeyApiClient {
/**
* Get active Console provider metadata
@@ -1180,6 +1218,11 @@ export class Experimental extends HeyApiClient {
return (this._controlPlane ??= new ControlPlane({ client: this.client }))
}
+ private _capabilities?: Capabilities
+ get capabilities(): Capabilities {
+ return (this._capabilities ??= new Capabilities({ client: this.client }))
+ }
+
private _console?: Console
get console(): Console {
return (this._console ??= new Console({ client: this.client }))
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index 093c1894a8a..e2add116e0e 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -2144,6 +2144,10 @@ export type Provider = {
}
}
+export type ExperimentalCapabilities = {
+ backgroundSubagents: boolean
+}
+
export type ConsoleState = {
consoleManagedProviders: Array
activeOrgName?: string
@@ -5549,6 +5553,36 @@ export type ConfigProvidersResponses = {
export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses]
+export type ExperimentalCapabilitiesGetData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/experimental/capabilities"
+}
+
+export type ExperimentalCapabilitiesGetErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type ExperimentalCapabilitiesGetError =
+ ExperimentalCapabilitiesGetErrors[keyof ExperimentalCapabilitiesGetErrors]
+
+export type ExperimentalCapabilitiesGetResponses = {
+ /**
+ * Experimental capabilities
+ */
+ 200: ExperimentalCapabilities
+}
+
+export type ExperimentalCapabilitiesGetResponse =
+ ExperimentalCapabilitiesGetResponses[keyof ExperimentalCapabilitiesGetResponses]
+
export type ExperimentalConsoleGetData = {
body?: never
path?: never
diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx
index 4882c13920f..d6c6a40ebf0 100644
--- a/packages/tui/src/context/sync.tsx
+++ b/packages/tui/src/context/sync.tsx
@@ -65,6 +65,9 @@ export const {
provider_default: Record
provider_next: ProviderListResponse
console_state: ConsoleState
+ capabilities: {
+ experimentalBackgroundSubagents: boolean
+ }
provider_auth: Record
agent: Agent[]
command: Command[]
@@ -107,6 +110,9 @@ export const {
connected: [],
},
console_state: emptyConsoleState,
+ capabilities: {
+ experimentalBackgroundSubagents: false,
+ },
provider_auth: {},
config: {},
status: "loading",
@@ -434,6 +440,10 @@ export const {
// blocking - include session.list when continuing a session
const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
+ const capabilitiesPromise = sdk.client.experimental.capabilities
+ .get({ workspace }, { throwOnError: true })
+ .then((x) => x.data)
+ .catch(() => undefined)
const consoleStatePromise = sdk.client.experimental.console
.get({ workspace }, { throwOnError: true })
.then((x) => x.data)
@@ -443,6 +453,7 @@ export const {
await Promise.all([
providersPromise,
providerListPromise,
+ capabilitiesPromise,
agentsPromise,
configPromise,
projectPromise,
@@ -451,6 +462,7 @@ export const {
.then(async () => {
const providersResponse = providersPromise.then((x) => x.data!)
const providerListResponse = providerListPromise.then((x) => x.data!)
+ const capabilitiesResponse = capabilitiesPromise
const consoleStateResponse = consoleStatePromise
const agentsResponse = agentsPromise.then((x) => x.data ?? [])
const configResponse = configPromise.then((x) => x.data!)
@@ -459,6 +471,7 @@ export const {
return Promise.all([
providersResponse,
providerListResponse,
+ capabilitiesResponse,
consoleStateResponse,
agentsResponse,
configResponse,
@@ -466,15 +479,21 @@ export const {
]).then((responses) => {
const providers = responses[0]
const providerList = responses[1]
- const consoleState = responses[2]
- const agents = responses[3]
- const config = responses[4]
- const sessions = responses[5]
+ const capabilities = responses[2]
+ const consoleState = responses[3]
+ const agents = responses[4]
+ const config = responses[5]
+ const sessions = responses[6]
batch(() => {
setStore("provider", reconcile(providers.providers))
setStore("provider_default", reconcile(providers.default))
setStore("provider_next", reconcile(providerList))
+ setStore(
+ "capabilities",
+ "experimentalBackgroundSubagents",
+ capabilities?.backgroundSubagents === true,
+ )
setStore("console_state", reconcile(consoleState))
setStore("agent", reconcile(agents))
setStore("config", reconcile(config))
diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx
index 1a1eb0fcc15..4d983ee99b7 100644
--- a/packages/tui/src/routes/session/index.tsx
+++ b/packages/tui/src/routes/session/index.tsx
@@ -206,15 +206,17 @@ export function Session() {
})
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const foregroundTasks = createMemo(() =>
- messages().flatMap((message) =>
- (sync.data.part[message.id] ?? []).filter(
- (part): part is ToolPart =>
- part.type === "tool" &&
- part.tool === "task" &&
- part.state.status === "running" &&
- part.state.metadata?.background !== true,
- ),
- ),
+ sync.data.capabilities.experimentalBackgroundSubagents
+ ? messages().flatMap((message) =>
+ (sync.data.part[message.id] ?? []).filter(
+ (part): part is ToolPart =>
+ part.type === "tool" &&
+ part.tool === "task" &&
+ part.state.status === "running" &&
+ part.state.metadata?.background !== true,
+ ),
+ )
+ : [],
)
const userMessageIDs = createMemo(
() =>
@@ -1510,13 +1512,16 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
{childShortcut()}
view subagents
- x.type === "tool" &&
- x.tool === "task" &&
- x.state.status === "running" &&
- x.state.metadata?.background !== true,
- )}
+ when={
+ sync.data.capabilities.experimentalBackgroundSubagents &&
+ props.parts.some(
+ (x) =>
+ x.type === "tool" &&
+ x.tool === "task" &&
+ x.state.status === "running" &&
+ x.state.metadata?.background !== true,
+ )
+ }
>
·
{backgroundShortcut()}
diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts
index 1d3bdd57579..ed18b7acde5 100644
--- a/packages/tui/test/fixture/tui-sdk.ts
+++ b/packages/tui/test/fixture/tui-sdk.ts
@@ -58,6 +58,7 @@ export function createFetch(override?: FetchHandler) {
return json({})
if (url.pathname === "/config/providers") return json({ providers: {}, default: {} })
if (url.pathname === "/experimental/console") return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
+ if (url.pathname === "/experimental/capabilities") return json({ backgroundSubagents: false })
if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
if (url.pathname === "/api/location") return json({ directory, project: { id: "proj_test", directory: worktree } })
if (
From 355a0bcf5bb5e6c7baa271a4b2439a40f286e55d Mon Sep 17 00:00:00 2001
From: "opencode-agent[bot]"
Date: Thu, 18 Jun 2026 12:56:22 +0000
Subject: [PATCH 004/112] chore: generate
---
.../test/server/httpapi-exercise/index.ts | 10 ++-
packages/sdk/openapi.json | 64 +++++++++++++++++++
packages/tui/src/context/sync.tsx | 6 +-
3 files changed, 69 insertions(+), 11 deletions(-)
diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts
index e8fdadd8ed2..b1f7bd8b725 100644
--- a/packages/opencode/test/server/httpapi-exercise/index.ts
+++ b/packages/opencode/test/server/httpapi-exercise/index.ts
@@ -578,12 +578,10 @@ const scenarios: Scenario[] = [
.get("/experimental/session", "experimental.session.list")
.at((ctx) => ({ path: "/experimental/session?roots=false&archived=false", headers: ctx.headers() }))
.json(200, array),
- http.protected
- .get("/experimental/capabilities", "experimental.capabilities.get")
- .json(200, (body) => {
- check(typeof body === "object" && body !== null, "capabilities should be an object")
- check("backgroundSubagents" in body, "capabilities should report background subagents")
- }),
+ http.protected.get("/experimental/capabilities", "experimental.capabilities.get").json(200, (body) => {
+ check(typeof body === "object" && body !== null, "capabilities should be an object")
+ check("backgroundSubagents" in body, "capabilities should report background subagents")
+ }),
http.protected
.post("/experimental/session/{sessionID}/background", "experimental.session.background")
.mutating()
diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json
index 48350fe13df..b0cf1678c78 100644
--- a/packages/sdk/openapi.json
+++ b/packages/sdk/openapi.json
@@ -810,6 +810,60 @@
]
}
},
+ "/experimental/capabilities": {
+ "get": {
+ "tags": ["experimental"],
+ "operationId": "experimental.capabilities.get",
+ "parameters": [
+ {
+ "name": "directory",
+ "in": "query",
+ "schema": {
+ "type": "string"
+ },
+ "required": false
+ },
+ {
+ "name": "workspace",
+ "in": "query",
+ "schema": {
+ "type": "string"
+ },
+ "required": false
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Experimental capabilities",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ExperimentalCapabilities"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BadRequestError"
+ }
+ }
+ }
+ }
+ },
+ "description": "Get experimental features enabled on the OpenCode server.",
+ "summary": "Get experimental capabilities",
+ "x-codeSamples": [
+ {
+ "lang": "js",
+ "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.capabilities.get({\n ...\n})"
+ }
+ ]
+ }
+ },
"/experimental/console": {
"get": {
"tags": ["experimental"],
@@ -21024,6 +21078,16 @@
"required": ["id", "name", "source", "env", "options", "models"],
"additionalProperties": false
},
+ "ExperimentalCapabilities": {
+ "type": "object",
+ "properties": {
+ "backgroundSubagents": {
+ "type": "boolean"
+ }
+ },
+ "required": ["backgroundSubagents"],
+ "additionalProperties": false
+ },
"ConsoleState": {
"type": "object",
"properties": {
diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx
index d6c6a40ebf0..03db8784de4 100644
--- a/packages/tui/src/context/sync.tsx
+++ b/packages/tui/src/context/sync.tsx
@@ -489,11 +489,7 @@ export const {
setStore("provider", reconcile(providers.providers))
setStore("provider_default", reconcile(providers.default))
setStore("provider_next", reconcile(providerList))
- setStore(
- "capabilities",
- "experimentalBackgroundSubagents",
- capabilities?.backgroundSubagents === true,
- )
+ setStore("capabilities", "experimentalBackgroundSubagents", capabilities?.backgroundSubagents === true)
setStore("console_state", reconcile(consoleState))
setStore("agent", reconcile(agents))
setStore("config", reconcile(config))
From 0f6c9b387499f40fe32c75843e6d6fad52912c35 Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Fri, 19 Jun 2026 11:36:26 -0500
Subject: [PATCH 005/112] chore(stats): update data seo metadata
---
packages/stats/app/src/app.tsx | 7 +++++--
packages/stats/app/src/routes/[lab]/[model].tsx | 6 ++----
packages/stats/app/src/routes/[lab]/index.tsx | 4 ++--
packages/stats/app/src/routes/index.tsx | 5 +++--
4 files changed, 12 insertions(+), 10 deletions(-)
diff --git a/packages/stats/app/src/app.tsx b/packages/stats/app/src/app.tsx
index b969043abd1..c3e66c0488f 100644
--- a/packages/stats/app/src/app.tsx
+++ b/packages/stats/app/src/app.tsx
@@ -7,8 +7,11 @@ import "./app.css"
function AppMeta() {
return (
<>
- OpenCode Data
-
+ AI Model Usage Rankings | OpenCode Data
+
>
)
}
diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx
index cd7e43dfc9a..8614a03d077 100644
--- a/packages/stats/app/src/routes/[lab]/[model].tsx
+++ b/packages/stats/app/src/routes/[lab]/[model].tsx
@@ -120,11 +120,9 @@ export default function StatsModel() {
const [themePreference, setThemePreference] = createSignal("system")
const modelName = createMemo(() => catalogEntry()?.name ?? stats()?.model ?? modelParam() ?? "Model")
const labName = createMemo(() => formatCatalogLabName(catalogEntry()?.lab ?? stats()?.provider ?? labParam()))
- const modelTitle = createMemo(() => `${modelName()} Data`)
+ const modelTitle = createMemo(() => `${modelName()} Usage, Cost & Rank | OpenCode Data`)
const modelDescription = createMemo(() =>
- stats()
- ? `${modelName()} usage, rank, token mix, cost, geo breakdown, and peer data across OpenCode Go.`
- : `${modelName()} model facts, limits, and OpenCode Go usage availability.`,
+ `View ${modelName()} OpenCode Go usage data, including token volume, weekly rank, token mix, costs, cache ratio, sessions, geo breakdowns, and peer models.`,
)
const modelUrl = createMemo(() =>
new URL(
diff --git a/packages/stats/app/src/routes/[lab]/index.tsx b/packages/stats/app/src/routes/[lab]/index.tsx
index 18f9545d08a..1c4cc7cc08a 100644
--- a/packages/stats/app/src/routes/[lab]/index.tsx
+++ b/packages/stats/app/src/routes/[lab]/index.tsx
@@ -69,10 +69,10 @@ export default function StatsLab() {
const githubStars = createAsync(() => getGitHubStars())
const [themePreference, setThemePreference] = createSignal("system")
const labName = createMemo(() => lab()?.name ?? formatCatalogLabName(labParam()))
- const labTitle = createMemo(() => `${labName()} Models`)
+ const labTitle = createMemo(() => `${labName()} AI Model Usage & Rankings | OpenCode Data`)
const labDescription = createMemo(
() =>
- `Explore ${labName()} models used in OpenCode, with recent token usage, context windows, release dates, and model-specific data.`,
+ `Compare ${labName()} models used in OpenCode Go, including token usage, model rankings, context windows, release dates, costs, and model-specific data.`,
)
const labUrl = createMemo(() => new URL(lab()?.id ?? labParam(), statsCanonicalBaseUrl).toString())
const updateThemePreference = (preference: ThemePreference) => {
diff --git a/packages/stats/app/src/routes/index.tsx b/packages/stats/app/src/routes/index.tsx
index 6ee3b46e2ae..7f528015a0f 100644
--- a/packages/stats/app/src/routes/index.tsx
+++ b/packages/stats/app/src/routes/index.tsx
@@ -49,8 +49,9 @@ const rangeLabels: Record = {
"1M": "1 Month",
"2M": "2 Months",
}
-const statsHomeTitle = "OpenCode Data"
-const statsHomeDescription = "OpenCode usage data, market share, token cost, and session cost."
+const statsHomeTitle = "AI Model Usage Rankings | OpenCode Data"
+const statsHomeDescription =
+ "Explore OpenCode Go usage across AI models, including token volume, rankings, market share, token pricing, session cost, cache ratio, and geo breakdowns."
const statsHomeFallbackUrl = "https://opencode.ai/data/"
const statsUnfurlPath = "banner.jpg"
const statsUnfurlAlt = "OpenCode Data wordmark on a dark patterned background"
From 10ec856ff21c0f137d4c17bdee650203493f2c62 Mon Sep 17 00:00:00 2001
From: "opencode-agent[bot]"
Date: Fri, 19 Jun 2026 16:38:37 +0000
Subject: [PATCH 006/112] chore: generate
---
packages/stats/app/src/routes/[lab]/[model].tsx | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx
index 8614a03d077..92f99fe7d3e 100644
--- a/packages/stats/app/src/routes/[lab]/[model].tsx
+++ b/packages/stats/app/src/routes/[lab]/[model].tsx
@@ -121,8 +121,9 @@ export default function StatsModel() {
const modelName = createMemo(() => catalogEntry()?.name ?? stats()?.model ?? modelParam() ?? "Model")
const labName = createMemo(() => formatCatalogLabName(catalogEntry()?.lab ?? stats()?.provider ?? labParam()))
const modelTitle = createMemo(() => `${modelName()} Usage, Cost & Rank | OpenCode Data`)
- const modelDescription = createMemo(() =>
- `View ${modelName()} OpenCode Go usage data, including token volume, weekly rank, token mix, costs, cache ratio, sessions, geo breakdowns, and peer models.`,
+ const modelDescription = createMemo(
+ () =>
+ `View ${modelName()} OpenCode Go usage data, including token volume, weekly rank, token mix, costs, cache ratio, sessions, geo breakdowns, and peer models.`,
)
const modelUrl = createMemo(() =>
new URL(
From c6083a474c6a6fce5df631b3e1464e12740238e7 Mon Sep 17 00:00:00 2001
From: Luke Parker <10430890+Hona@users.noreply.github.com>
Date: Fri, 19 Jun 2026 19:39:02 +0200
Subject: [PATCH 007/112] test(app): add manual performance diagnostics
(#32937)
---
packages/app/AGENTS.md | 5 +
packages/app/e2e/performance/AGENTS.md | 13 +
packages/app/e2e/performance/README.md | 77 +++
packages/app/e2e/performance/benchmark.ts | 144 +++++
packages/app/e2e/performance/chrome-trace.ts | 95 +++
.../app/e2e/performance/playwright.config.ts | 20 +
.../performance/playwright.uncapped.config.ts | 13 +
.../timeline/session-tab-flash.spec.ts | 49 ++
.../timeline/session-tab-repaint-probe.ts | 251 ++++++++
.../session-tab-switch-benchmark.spec.ts | 79 +++
.../timeline/session-tab-switch-metrics.ts | 46 ++
.../timeline/session-tab-switch-probe.ts | 152 +++++
.../session-timeline-benchmark.fixture.ts | 488 ++++++++++++++++
.../session-timeline-benchmark.spec.ts | 85 +++
.../timeline/session-timeline-profile.ts | 40 ++
.../timeline/session-timeline-stream-probe.ts | 547 ++++++++++++++++++
.../session-timeline-stress.fixture.ts | 335 +++++++++++
.../timeline/timeline-test-helpers.ts | 67 +++
.../unit/chrome-trace-write.test.ts | 15 +
.../unit/session-tab-repaint-probe.test.ts | 42 ++
.../unit/session-tab-switch-metrics.test.ts | 54 ++
.../session-timeline-stream-probe.test.ts | 14 +
.../session-timeline-visual-tracking.test.ts | 16 +
packages/app/e2e/utils/mock-server.ts | 8 +-
packages/app/package.json | 3 +-
packages/app/playwright.config.ts | 1 +
26 files changed, 2656 insertions(+), 3 deletions(-)
create mode 100644 packages/app/e2e/performance/AGENTS.md
create mode 100644 packages/app/e2e/performance/README.md
create mode 100644 packages/app/e2e/performance/benchmark.ts
create mode 100644 packages/app/e2e/performance/chrome-trace.ts
create mode 100644 packages/app/e2e/performance/playwright.config.ts
create mode 100644 packages/app/e2e/performance/playwright.uncapped.config.ts
create mode 100644 packages/app/e2e/performance/timeline/session-tab-flash.spec.ts
create mode 100644 packages/app/e2e/performance/timeline/session-tab-repaint-probe.ts
create mode 100644 packages/app/e2e/performance/timeline/session-tab-switch-benchmark.spec.ts
create mode 100644 packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts
create mode 100644 packages/app/e2e/performance/timeline/session-tab-switch-probe.ts
create mode 100644 packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts
create mode 100644 packages/app/e2e/performance/timeline/session-timeline-benchmark.spec.ts
create mode 100644 packages/app/e2e/performance/timeline/session-timeline-profile.ts
create mode 100644 packages/app/e2e/performance/timeline/session-timeline-stream-probe.ts
create mode 100644 packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts
create mode 100644 packages/app/e2e/performance/timeline/timeline-test-helpers.ts
create mode 100644 packages/app/e2e/performance/unit/chrome-trace-write.test.ts
create mode 100644 packages/app/e2e/performance/unit/session-tab-repaint-probe.test.ts
create mode 100644 packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts
create mode 100644 packages/app/e2e/performance/unit/session-timeline-stream-probe.test.ts
create mode 100644 packages/app/e2e/performance/unit/session-timeline-visual-tracking.test.ts
diff --git a/packages/app/AGENTS.md b/packages/app/AGENTS.md
index 765e960c817..2e56066e7d1 100644
--- a/packages/app/AGENTS.md
+++ b/packages/app/AGENTS.md
@@ -1,3 +1,8 @@
+## Priorities
+
+- Prioritise, in this order: stability, simplicity, performance.
+- Before changing session or timeline code, record a production benchmark baseline and compare it after the change.
+
## Debugging
- NEVER try to restart the app, or the server process, EVER.
diff --git a/packages/app/e2e/performance/AGENTS.md b/packages/app/e2e/performance/AGENTS.md
new file mode 100644
index 00000000000..e69c91a98fc
--- /dev/null
+++ b/packages/app/e2e/performance/AGENTS.md
@@ -0,0 +1,13 @@
+- Prioritize stability, then simplicity, then measurement overhead.
+- Use Playwright for scenario control, isolation, and completion checks.
+- Use Chrome Performance traces for generic browser profiling.
+- Use Electron `contentTracing` for packaged multi-process profiling.
+- Keep custom probes only for product-specific measurements.
+- Do not duplicate measurements across the harness, probes, and traces.
+- Run benchmarks serially to avoid cross-test contention.
+- Run benchmarks against production builds.
+- Keep detailed profiling opt-in when it changes workload behavior.
+- Preserve raw diagnostic data or use lossless representations.
+- Do not enforce machine-dependent performance thresholds.
+- Assert scenario completion and metric collection only.
+- Keep normal test discovery free of manual benchmarks.
diff --git a/packages/app/e2e/performance/README.md b/packages/app/e2e/performance/README.md
new file mode 100644
index 00000000000..afa3108d5cc
--- /dev/null
+++ b/packages/app/e2e/performance/README.md
@@ -0,0 +1,77 @@
+# Manual app performance suite
+
+The app's high-volume performance diagnostics live under `packages/app/e2e/performance` and are excluded from normal local and CI Playwright discovery. The benchmark config builds the app and serves the production bundle before running scenarios serially.
+
+Run the suite explicitly from `packages/app`:
+
+```sh
+bun run test:bench
+```
+
+PowerShell:
+
+```powershell
+$env:PLAYWRIGHT_WORKERS = "1"
+bun run test:bench
+```
+
+The suite contains:
+
+- cold and hot session-tab timing
+- cached session repaint and mutation tracing
+- streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics
+
+All benchmarks import the shared `benchmark` fixture. Pages created through Playwright's `page` fixture automatically capture main-frame navigation history and emit a Chrome trace when `OPENCODE_PERFORMANCE_TRACE_DIR` is set. Benchmarks that need isolated browser contexts use `withBenchmarkPage`, which owns the context and the same diagnostics lifecycle.
+
+New benchmarks should look like normal Playwright tests:
+
+```ts
+import { benchmark, expect } from "../benchmark"
+
+benchmark("measures one interaction", async ({ page, report }) => {
+ // Only scenario-specific setup and interaction belong here.
+ report({ durationMs: 42 })
+})
+```
+
+The fixture requires every benchmark to call `report()`, automatically names and closes traces, captures navigation history, attaches that history when a test fails, and emits metrics as a consistent `BENCHMARK` JSON line.
+
+```text
+BENCHMARK {"name":"...","context":{"project":"chromium","platform":"darwin"},"metrics":{...}}
+```
+
+Every observed page also emits `BENCHMARK_PAGE` with the same run ID, navigation history, and optional trace path before the final status-bearing `BENCHMARK` record. Chrome traces are browser-wide page-lifetime diagnostics; scenario metrics use narrower explicitly named observation windows.
+
+This follows the stack's own guidance: [Electron recommends repeated Chrome DevTools and Chrome Tracing measurement](https://www.electronjs.org/docs/latest/tutorial/performance), [Chrome DevTools recommends Performance recordings for runtime work](https://developer.chrome.com/docs/devtools/performance), and [Playwright uses traces for test debugging rather than renderer profiling](https://playwright.dev/docs/trace-viewer).
+
+These Playwright benchmarks profile the shared app renderer in Chromium. A future packaged Electron benchmark that needs main-process and multi-process attribution should use Electron's official [`contentTracing`](https://www.electronjs.org/docs/latest/api/content-tracing/) API rather than extending this renderer harness with bespoke process instrumentation.
+
+CPU and high-volume visual profiling are disabled by default. Set `TIMELINE_CPU_PROFILE=1` to enable both, or additionally set `TIMELINE_VISUAL_PROFILE=0` for CPU-only profiling.
+
+The streaming scenario's 30x CPU throttle is a deterministic stress profile, not a simulated end-user device.
+
+Benchmarks do not assert machine-dependent performance budgets. Streaming processes 160 deltas by default and reports renderer-observed completion time, throughput, RAF callback-gap distributions, frame-budget equivalents, and long tasks through final geometry settlement. Delta count and delivery batch are included in result context when overridden. These are main-thread callback diagnostics, not compositor presentation or dropped-frame measurements. Visual-only and geometry metrics are `null` when their probes are disabled. Tab metrics describe sampled DOM observations. Assertions verify scenario and metric collection completion. Repeated repaint states are run-length grouped, but every original observation timestamp is retained alongside raw mutation batches and layout shifts.
+
+Committed smoke and regression tests continue to own correctness coverage for pagination, tab paint, context resize, collapse state, and composer spacing.
+
+## Chrome traces
+
+Set `OPENCODE_PERFORMANCE_TRACE_DIR` to emit a standard Chrome DevTools trace for every benchmark page automatically:
+
+```sh
+OPENCODE_PERFORMANCE_TRACE_DIR=/tmp/opencode-performance-traces \
+bunx playwright test --config e2e/performance/playwright.config.ts \
+ timeline/session-tab-switch-benchmark.spec.ts
+```
+
+The emitted JSON is a standard Chrome trace and can be loaded directly into the Chrome DevTools Performance panel. `devtools-tracing` can optionally inspect it from the command line without adding package scripts or dependencies:
+
+Trace capture mirrors [Puppeteer's official tracing defaults and lifecycle](https://pptr.dev/api/puppeteer.tracing), using Chrome's `ReturnAsStream` transfer mode and failing when Chromium reports trace data loss.
+
+```sh
+bunx devtools-tracing stats
+```
+
+INP analysis requires a trace with a supported navigation/interaction insight. Selector statistics require a trace captured with `OPENCODE_PERFORMANCE_SELECTOR_TRACE=1`.
+
+`e2e/performance/playwright.uncapped.config.ts` disables Chromium frame-rate limiting for explicit uncapped diagnostics. Native product benchmarks should use the default Playwright configuration.
diff --git a/packages/app/e2e/performance/benchmark.ts b/packages/app/e2e/performance/benchmark.ts
new file mode 100644
index 00000000000..b9f8ea43411
--- /dev/null
+++ b/packages/app/e2e/performance/benchmark.ts
@@ -0,0 +1,144 @@
+import { expect, test as base, type Browser, type Page, type TestInfo } from "@playwright/test"
+import { startChromeTrace } from "./chrome-trace"
+
+type BenchmarkFixtures = {
+ report: (metrics: Record, context?: Record) => void
+ reportState: { payload?: { metrics: Record; context: Record } }
+ benchmarkResult: void
+}
+
+export type PerformancePageDiagnostics = {
+ navigations: string[]
+ stop: () => Promise
+}
+
+const pages = new WeakMap()
+
+export const benchmark = base.extend({
+ reportState: async ({}, use) => use({}),
+ report: async ({ reportState }, use) => {
+ await use((metrics, context = {}) => {
+ if (reportState.payload) throw new Error("Benchmark reported metrics more than once")
+ reportState.payload = { metrics, context }
+ })
+ },
+ benchmarkResult: [
+ async ({ reportState }, use, testInfo) => {
+ await use()
+ const missing = !reportState.payload
+ console.log(
+ `BENCHMARK ${JSON.stringify({
+ schemaVersion: 2,
+ runID: process.env.OPENCODE_PERFORMANCE_RUN_ID,
+ name: benchmarkName(testInfo),
+ status: missing ? "failed" : testInfo.status,
+ expectedStatus: testInfo.expectedStatus,
+ retry: testInfo.retry,
+ repeatEachIndex: testInfo.repeatEachIndex,
+ context: {
+ project: testInfo.project.name,
+ platform: process.platform,
+ ...reportState.payload?.context,
+ },
+ metrics: reportState.payload?.metrics ?? null,
+ error: missing ? "Benchmark did not report metrics" : undefined,
+ })}`,
+ )
+ if (missing && testInfo.status === testInfo.expectedStatus)
+ throw new Error(`Benchmark did not report metrics: ${benchmarkName(testInfo)}`)
+ },
+ { auto: true },
+ ],
+ page: async ({ page }, use, testInfo) => {
+ const name = benchmarkName(testInfo)
+ const diagnostics = await observePerformancePage(page, name)
+ try {
+ await use(page)
+ } finally {
+ try {
+ await reportPerformancePage(name, diagnostics, testInfo)
+ } finally {
+ if (testInfo.status !== testInfo.expectedStatus) {
+ await testInfo.attach("performance-navigations", {
+ body: JSON.stringify(diagnostics.navigations, null, 2),
+ contentType: "application/json",
+ })
+ }
+ }
+ }
+ },
+})
+
+function benchmarkName(testInfo: TestInfo) {
+ return testInfo.titlePath.slice(1).join(" > ")
+}
+
+export { expect }
+
+async function observePerformancePage(page: Page, name: string) {
+ const navigations: string[] = []
+ const onNavigation = (frame: ReturnType) => {
+ if (frame === page.mainFrame()) navigations.push(frame.url())
+ }
+ page.on("framenavigated", onNavigation)
+ const stopTrace = await startChromeTrace(page, name).catch((error) => {
+ page.off("framenavigated", onNavigation)
+ throw error
+ })
+ let stopping: Promise | undefined
+ const diagnostics: PerformancePageDiagnostics = {
+ navigations,
+ stop() {
+ page.off("framenavigated", onNavigation)
+ return (stopping ??= stopTrace?.() ?? Promise.resolve(undefined))
+ },
+ }
+ pages.set(page, diagnostics)
+ return diagnostics
+}
+
+export async function withBenchmarkPage(
+ browser: Browser,
+ name: string,
+ run: (page: Page) => Promise,
+ testInfo?: TestInfo,
+) {
+ const context = await browser.newContext()
+ try {
+ const page = await context.newPage()
+ const diagnostics = await observePerformancePage(page, name)
+ try {
+ return await run(page)
+ } finally {
+ await reportPerformancePage(name, diagnostics, testInfo)
+ }
+ } finally {
+ await context.close()
+ }
+}
+
+async function reportPerformancePage(name: string, diagnostics: PerformancePageDiagnostics, testInfo?: TestInfo) {
+ const trace = await diagnostics.stop()
+ console.log(
+ `BENCHMARK_PAGE ${JSON.stringify({
+ schemaVersion: 2,
+ runID: process.env.OPENCODE_PERFORMANCE_RUN_ID,
+ name,
+ test: testInfo ? benchmarkName(testInfo) : undefined,
+ retry: testInfo?.retry,
+ repeatEachIndex: testInfo?.repeatEachIndex,
+ context: {
+ platform: process.platform,
+ trace,
+ selectorTrace: process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1",
+ },
+ navigations: diagnostics.navigations,
+ })}`,
+ )
+}
+
+export function benchmarkDiagnostics(page: Page) {
+ const diagnostics = pages.get(page)
+ if (!diagnostics) throw new Error("Performance diagnostics are not installed for this page")
+ return diagnostics
+}
diff --git a/packages/app/e2e/performance/chrome-trace.ts b/packages/app/e2e/performance/chrome-trace.ts
new file mode 100644
index 00000000000..343526e254d
--- /dev/null
+++ b/packages/app/e2e/performance/chrome-trace.ts
@@ -0,0 +1,95 @@
+import type { CDPSession, Page } from "@playwright/test"
+import path from "node:path"
+import { mkdir, open, rename } from "node:fs/promises"
+import { Buffer } from "node:buffer"
+import { createHash, randomUUID } from "node:crypto"
+
+const categories = [
+ "-*",
+ "devtools.timeline",
+ "v8.execute",
+ "disabled-by-default-devtools.timeline",
+ "disabled-by-default-devtools.timeline.frame",
+ "toplevel",
+ "blink.console",
+ "blink.user_timing",
+ "latencyInfo",
+ "disabled-by-default-devtools.timeline.stack",
+ "disabled-by-default-v8.cpu_profiler",
+]
+
+export async function startChromeTrace(page: Page, name: string) {
+ const directory = process.env.OPENCODE_PERFORMANCE_TRACE_DIR
+ if (!directory) return
+
+ const selectors = process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1"
+ const file = await prepareChromeTrace(directory, name, selectors)
+ const session = await page.context().newCDPSession(page)
+ try {
+ await session.send("Tracing.start", {
+ transferMode: "ReturnAsStream",
+ traceConfig: {
+ excludedCategories: categories
+ .filter((category) => category.startsWith("-"))
+ .map((category) => category.slice(1)),
+ includedCategories: [
+ ...categories.filter((category) => !category.startsWith("-")),
+ ...(selectors
+ ? ["disabled-by-default-blink.debug", "disabled-by-default-devtools.timeline.invalidationTracking"]
+ : []),
+ ],
+ },
+ })
+ } catch (error) {
+ await Promise.allSettled([session.detach()])
+ throw error
+ }
+ let stopping: Promise | undefined
+
+ return () =>
+ (stopping ??= (async () => {
+ try {
+ const complete = new Promise<{ stream?: string; dataLossOccurred: boolean }>((resolve) =>
+ session.once("Tracing.tracingComplete", resolve),
+ )
+ await session.send("Tracing.end")
+ const result = await complete
+ if (!result.stream) throw new Error(`Chrome trace stream missing: ${file}`)
+ const partial = `${file}.partial`
+ await writeProtocolStream(session, result.stream, partial)
+ if (result.dataLossOccurred) throw new Error(`Chrome trace lost data; partial capture retained: ${partial}`)
+ await rename(partial, file)
+ return file
+ } finally {
+ await Promise.allSettled([session.detach()])
+ }
+ })())
+}
+
+export async function prepareChromeTrace(
+ directory: string,
+ name: string,
+ selectors: boolean,
+ nonce = randomUUID().slice(0, 8),
+) {
+ await mkdir(directory, { recursive: true })
+ const run = process.env.OPENCODE_PERFORMANCE_RUN_ID ?? "manual"
+ const hash = createHash("sha256").update(name).digest("hex").slice(0, 8)
+ return path.join(
+ directory,
+ `${run}-${name.replace(/[^a-zA-Z0-9_-]/g, "-")}-${hash}-${nonce}${selectors ? "-selectors" : ""}.json`,
+ )
+}
+
+async function writeProtocolStream(session: CDPSession, handle: string, file: string) {
+ const output = await open(file, "wx")
+ try {
+ while (true) {
+ const chunk = await session.send("IO.read", { handle })
+ await output.write(chunk.base64Encoded ? Buffer.from(chunk.data, "base64") : chunk.data)
+ if (chunk.eof) break
+ }
+ } finally {
+ await Promise.allSettled([output.close(), session.send("IO.close", { handle })])
+ }
+}
diff --git a/packages/app/e2e/performance/playwright.config.ts b/packages/app/e2e/performance/playwright.config.ts
new file mode 100644
index 00000000000..d4793daee58
--- /dev/null
+++ b/packages/app/e2e/performance/playwright.config.ts
@@ -0,0 +1,20 @@
+import config from "../../playwright.config"
+
+const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000)
+process.env.PLAYWRIGHT_SERVER_PORT = String(port)
+process.env.OPENCODE_PERFORMANCE_RUN_ID ??= `${new Date().toISOString().replace(/[:.]/g, "-")}-${process.pid}`
+
+export default {
+ ...config,
+ testDir: ".",
+ testIgnore: "unit/**",
+ outputDir: "../test-results/performance",
+ fullyParallel: false,
+ workers: 1,
+ reporter: [["html", { outputFolder: "../playwright-report/performance", open: "never" }], ["line"]],
+ webServer: {
+ ...config.webServer,
+ command: `bun run build && bun run serve -- --host 0.0.0.0 --port ${port} --strictPort`,
+ reuseExistingServer: false,
+ },
+}
diff --git a/packages/app/e2e/performance/playwright.uncapped.config.ts b/packages/app/e2e/performance/playwright.uncapped.config.ts
new file mode 100644
index 00000000000..9097c11f1d0
--- /dev/null
+++ b/packages/app/e2e/performance/playwright.uncapped.config.ts
@@ -0,0 +1,13 @@
+import config from "./playwright.config"
+
+export default {
+ ...config,
+ outputDir: "../test-results/performance-uncapped",
+ reporter: [["html", { outputFolder: "../playwright-report/performance-uncapped", open: "never" }], ["line"]],
+ use: {
+ ...config.use,
+ launchOptions: {
+ args: ["--disable-frame-rate-limit", "--disable-gpu-vsync"],
+ },
+ },
+}
diff --git a/packages/app/e2e/performance/timeline/session-tab-flash.spec.ts b/packages/app/e2e/performance/timeline/session-tab-flash.spec.ts
new file mode 100644
index 00000000000..741084751f1
--- /dev/null
+++ b/packages/app/e2e/performance/timeline/session-tab-flash.spec.ts
@@ -0,0 +1,49 @@
+import { benchmark, expect } from "../benchmark"
+import { expectSessionTitle } from "../../utils/waits"
+import { fixture } from "./session-timeline-stress.fixture"
+import {
+ collectCachedRepaintTrace,
+ compressCachedRepaintTrace,
+ installCachedRepaintProbe,
+ waitForCachedRepaintWindow,
+} from "./session-tab-repaint-probe"
+import { waitForStableTimeline } from "./session-tab-switch-probe"
+import {
+ installStressSessionTabs,
+ installTimelineSettings,
+ mockStressTimeline,
+ stressSessionHref,
+} from "./timeline-test-helpers"
+
+benchmark("samples cached session repaint after the click", async ({ page, report }) => {
+ benchmark.setTimeout(120_000)
+ await mockStressTimeline(page)
+ await installStressSessionTabs(page)
+ await installTimelineSettings(page)
+ await page.goto(stressSessionHref(fixture.targetID))
+ await expectSessionTitle(page, fixture.expected.targetTitle)
+ await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
+ await page
+ .locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.sourceID)}"]`)
+ .first()
+ .click()
+ await expectSessionTitle(page, fixture.expected.sourceTitle)
+ await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
+
+ await installCachedRepaintProbe(page, {
+ targetHref: stressSessionHref(fixture.targetID),
+ destination: fixture.messages[fixture.targetID].map((message) => message.info.id),
+ source: fixture.messages[fixture.sourceID].map((message) => message.info.id),
+ last: fixture.expected.targetMessageIDs.at(-1)!,
+ windowMs: 1_000,
+ })
+
+ await page
+ .locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.targetID)}"]`)
+ .first()
+ .click()
+ await Promise.all([expectSessionTitle(page, fixture.expected.targetTitle), waitForCachedRepaintWindow(page, 1_000)])
+ const result = await collectCachedRepaintTrace(page)
+ report(compressCachedRepaintTrace(result))
+ expect(result.samples.length).toBeGreaterThan(0)
+})
diff --git a/packages/app/e2e/performance/timeline/session-tab-repaint-probe.ts b/packages/app/e2e/performance/timeline/session-tab-repaint-probe.ts
new file mode 100644
index 00000000000..862e080f13d
--- /dev/null
+++ b/packages/app/e2e/performance/timeline/session-tab-repaint-probe.ts
@@ -0,0 +1,251 @@
+import type { Page } from "@playwright/test"
+
+type CachedRepaintTrace = {
+ timeOriginEpochMs: number
+ startedAtPerformanceMs: number
+ samples: {
+ observedAtMs: number
+ root: number | undefined
+ scrollTop: number
+ scrollHeight: number
+ bottomErrorPx: number | undefined
+ last: boolean
+ rows: { key: string | undefined; node: number; top: number; bottom: number }[]
+ mounted: number
+ center: string | undefined
+ destination: string[]
+ source: string[]
+ }[]
+ mutations: { observedAtMs: number; changed: { type: string; node: number }[] }[]
+ shifts: { occurredAtMs: number; value: number }[]
+ windowMs: number
+ running: boolean
+ stop: () => void
+}
+
+export async function installCachedRepaintProbe(
+ page: Page,
+ input: { targetHref: string; destination: string[]; source: string[]; last: string; windowMs: number },
+) {
+ await page.evaluate(({ targetHref, destination, source, last, windowMs }) => {
+ const destinationIDs = new Set(destination)
+ const sourceIDs = new Set(source)
+ const nodeIDs = new WeakMap()
+ let nextNodeID = 1
+ const id = (node: Node) => {
+ const current = nodeIDs.get(node)
+ if (current) return current
+ nodeIDs.set(node, nextNodeID)
+ return nextNodeID++
+ }
+ const state: CachedRepaintTrace = {
+ timeOriginEpochMs: performance.timeOrigin,
+ startedAtPerformanceMs: 0,
+ samples: [],
+ mutations: [],
+ shifts: [],
+ windowMs,
+ running: false,
+ stop: () => {},
+ }
+ const recordShifts = (entries: PerformanceEntry[]) => {
+ if (!state.running) return
+ state.shifts.push(
+ ...entries
+ .map((entry) => {
+ if (
+ entry.startTime < state.startedAtPerformanceMs ||
+ entry.startTime > state.startedAtPerformanceMs + state.windowMs
+ )
+ return
+ return {
+ occurredAtMs: entry.startTime - state.startedAtPerformanceMs,
+ value: (entry as PerformanceEntry & { value: number }).value,
+ }
+ })
+ .filter((entry): entry is { occurredAtMs: number; value: number } => entry !== undefined),
+ )
+ }
+ const shiftObserver = new PerformanceObserver((entries) => recordShifts(entries.getEntries()))
+ shiftObserver.observe({ type: "layout-shift" })
+ const recordMutations = (entries: MutationRecord[]) => {
+ if (!state.running) return
+ const observedAtMs = performance.now() - state.startedAtPerformanceMs
+ if (observedAtMs > state.windowMs) return
+ const changed = entries.flatMap((entry) => [
+ ...[...entry.addedNodes].map((node) => ({ type: "add", node: id(node) })),
+ ...[...entry.removedNodes].map((node) => ({ type: "remove", node: id(node) })),
+ ])
+ if (changed.length) state.mutations.push({ observedAtMs, changed })
+ }
+ const mutationObserver = new MutationObserver(recordMutations)
+ mutationObserver.observe(document.documentElement, { childList: true, subtree: true })
+ state.stop = () => {
+ recordShifts(shiftObserver.takeRecords())
+ recordMutations(mutationObserver.takeRecords())
+ state.running = false
+ shiftObserver.disconnect()
+ mutationObserver.disconnect()
+ }
+ const sample = () => {
+ if (!state.running) return
+ setTimeout(() => {
+ if (!state.running) return
+ const observedAtMs = performance.now() - state.startedAtPerformanceMs
+ if (observedAtMs > state.windowMs) return
+ const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) =>
+ element.querySelector("[data-timeline-row]"),
+ )
+ if (root) {
+ const view = root.getBoundingClientRect()
+ const rows = [...root.querySelectorAll("[data-timeline-key]")]
+ .map((element) => ({
+ key: element.dataset.timelineKey,
+ node: id(element),
+ rect: element.getBoundingClientRect(),
+ }))
+ .filter((item) => item.rect.bottom > view.top && item.rect.top < view.bottom)
+ .map((item) => ({
+ key: item.key,
+ node: item.node,
+ top: item.rect.top - view.top,
+ bottom: item.rect.bottom - view.top,
+ }))
+ const messages = [...root.querySelectorAll("[data-message-id]")]
+ .filter((element) => {
+ const rect = element.getBoundingClientRect()
+ return rect.bottom > view.top && rect.top < view.bottom
+ })
+ .map((element) => element.dataset.messageId!)
+ const spacer = root.querySelector('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
+ state.samples.push({
+ observedAtMs,
+ root: id(root),
+ scrollTop: root.scrollTop,
+ scrollHeight: root.scrollHeight,
+ bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
+ last: messages.includes(last),
+ rows,
+ mounted: root.querySelectorAll("[data-timeline-key]").length,
+ center: document
+ .elementFromPoint(view.left + view.width / 2, view.top + view.height / 2)
+ ?.textContent?.slice(0, 80),
+ destination: messages.filter((messageID) => destinationIDs.has(messageID)),
+ source: messages.filter((messageID) => sourceIDs.has(messageID)),
+ })
+ } else {
+ state.samples.push({
+ observedAtMs,
+ root: undefined,
+ scrollTop: 0,
+ scrollHeight: 0,
+ bottomErrorPx: undefined,
+ last: false,
+ rows: [],
+ mounted: 0,
+ center: document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.textContent?.slice(0, 80),
+ destination: [],
+ source: [],
+ })
+ }
+ requestAnimationFrame(sample)
+ }, 0)
+ }
+ document.addEventListener(
+ "click",
+ (event) => {
+ const link = event.target instanceof Element ? event.target.closest("a") : undefined
+ if (link?.getAttribute("href") !== targetHref) return
+ state.startedAtPerformanceMs = performance.now()
+ state.running = true
+ requestAnimationFrame(sample)
+ },
+ { capture: true, once: true },
+ )
+ ;(window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash = state
+ }, input)
+}
+
+export function layoutShiftSample(entry: Pick & { value: number }, started: number) {
+ if (entry.startTime < started) return
+ return { occurredAtMs: entry.startTime - started, value: entry.value }
+}
+
+export async function waitForCachedRepaintWindow(page: Page, durationMs: number) {
+ await page.waitForFunction((durationMs) => {
+ const state = (window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash
+ return !!state?.running && performance.now() - state.startedAtPerformanceMs >= durationMs
+ }, durationMs)
+}
+
+export async function collectCachedRepaintTrace(page: Page) {
+ return page.evaluate(() => {
+ const state = (window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash!
+ state.stop()
+ return state
+ })
+}
+
+export function summarizeCachedRepaintTrace(trace: CachedRepaintTrace) {
+ const roots = trace.samples.map((sample) => sample.root)
+ const bottomErrors = trace.samples.flatMap((sample) =>
+ sample.bottomErrorPx === undefined ? [] : [Math.abs(sample.bottomErrorPx)],
+ )
+ const category = (sample: CachedRepaintTrace["samples"][number]) => {
+ if (sample.source.length) return "source"
+ if (sample.root === undefined || sample.rows.length === 0) return "blank"
+ if (!sample.destination.length) return "unknown"
+ if (sample.last && Math.abs(sample.bottomErrorPx ?? Infinity) <= 1) return "correct"
+ return "wrongDestination"
+ }
+ return {
+ samples: trace.samples.length,
+ durationMs: trace.samples.at(-1)?.observedAtMs ?? 0,
+ firstSampleObservedMs: trace.samples[0]?.observedAtMs,
+ firstSampleCorrect: trace.samples[0] ? category(trace.samples[0]) === "correct" : false,
+ blankSamples: trace.samples.filter((sample) => category(sample) === "blank").length,
+ sourceSamples: trace.samples.filter((sample) => category(sample) === "source").length,
+ wrongDestinationSamples: trace.samples.filter((sample) => category(sample) === "wrongDestination").length,
+ unknownSamples: trace.samples.filter((sample) => category(sample) === "unknown").length,
+ rootChanges: roots.slice(1).filter((root, index) => root !== roots[index]).length,
+ mountedMin: trace.samples.length ? Math.min(...trace.samples.map((sample) => sample.mounted)) : 0,
+ mountedMax: Math.max(...trace.samples.map((sample) => sample.mounted)),
+ maxBottomErrorPx: Math.max(0, ...bottomErrors),
+ mutationBatches: trace.mutations.length,
+ addedNodes: trace.mutations.reduce(
+ (sum, batch) => sum + batch.changed.filter((change) => change.type === "add").length,
+ 0,
+ ),
+ removedNodes: trace.mutations.reduce(
+ (sum, batch) => sum + batch.changed.filter((change) => change.type === "remove").length,
+ 0,
+ ),
+ layoutShiftValueSum: trace.shifts.reduce((sum, shift) => sum + shift.value, 0),
+ maxLayoutShiftValue: Math.max(0, ...trace.shifts.map((shift) => shift.value)),
+ }
+}
+
+export function compressCachedRepaintTrace(trace: CachedRepaintTrace) {
+ const samples: {
+ observedAtMs: number[]
+ state: Omit
+ }[] = []
+ for (const sample of trace.samples) {
+ const { observedAtMs, ...state } = sample
+ const previous = samples.at(-1)
+ if (previous && JSON.stringify(previous.state) === JSON.stringify(state)) {
+ previous.observedAtMs.push(observedAtMs)
+ continue
+ }
+ samples.push({ observedAtMs: [observedAtMs], state })
+ }
+ return {
+ timeOriginEpochMs: trace.timeOriginEpochMs,
+ startedAtPerformanceMs: trace.startedAtPerformanceMs,
+ windowMs: trace.windowMs,
+ summary: summarizeCachedRepaintTrace(trace),
+ samples,
+ mutations: trace.mutations,
+ shifts: trace.shifts,
+ }
+}
diff --git a/packages/app/e2e/performance/timeline/session-tab-switch-benchmark.spec.ts b/packages/app/e2e/performance/timeline/session-tab-switch-benchmark.spec.ts
new file mode 100644
index 00000000000..2e80d703813
--- /dev/null
+++ b/packages/app/e2e/performance/timeline/session-tab-switch-benchmark.spec.ts
@@ -0,0 +1,79 @@
+import type { Page } from "@playwright/test"
+import { expectSessionTitle } from "../../utils/waits"
+import { benchmark, expect, withBenchmarkPage } from "../benchmark"
+import { fixture } from "./session-timeline-stress.fixture"
+import { installStressSessionTabs, mockStressTimeline, stressSessionHref } from "./timeline-test-helpers"
+import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
+
+type Result = Awaited>
+
+benchmark("benchmarks cold and hot session tab switching", async ({ browser, report }, testInfo) => {
+ benchmark.setTimeout(180_000)
+ const results = { cold: [] as Result[], hot: [] as Result[] }
+ for (const mode of ["cold", "hot"] as const) {
+ for (let run = 0; run < 5; run++) {
+ results[mode].push(
+ await withBenchmarkPage(browser, `session-tab-switch-${mode}-${run}`, (page) => trial(page, mode), testInfo),
+ )
+ }
+ }
+ report({ results, summary: summarize(results) })
+})
+
+async function trial(page: Page, mode: "cold" | "hot") {
+ await mockStressTimeline(page)
+ await installStressSessionTabs(page)
+ if (mode === "hot") {
+ await page.goto(stressSessionHref(fixture.targetID))
+ await expectSessionTitle(page, fixture.expected.targetTitle)
+ await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
+ await switchSession(page, fixture.sourceID, fixture.expected.sourceTitle)
+ } else {
+ await page.goto(stressSessionHref(fixture.sourceID))
+ await expectSessionTitle(page, fixture.expected.sourceTitle)
+ }
+ await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
+
+ const destinationIDs = fixture.messages[fixture.targetID].map((message) => message.info.id)
+ const sourceIDs = fixture.messages[fixture.sourceID].map((message) => message.info.id)
+ const lastID = fixture.expected.targetMessageIDs.at(-1)!
+ const href = stressSessionHref(fixture.targetID)
+ const result = await measureSessionSwitch(page, {
+ destinationIDs,
+ sourceIDs,
+ lastID,
+ href,
+ switch: () => switchSession(page, fixture.targetID, fixture.expected.targetTitle),
+ })
+ return result
+}
+
+function summarize(results: Record<"cold" | "hot", Result[]>) {
+ const stats = (values: (number | null)[]) => {
+ const sorted = values.filter((value): value is number => value !== null).sort((a, b) => a - b)
+ return {
+ min: sorted[0] ?? null,
+ median: sorted[Math.floor(sorted.length / 2)] ?? null,
+ max: sorted.at(-1) ?? null,
+ missing: values.length - sorted.length,
+ }
+ }
+ return Object.fromEntries(
+ Object.entries(results).map(([mode, values]) => [
+ mode,
+ {
+ firstDestinationObservedMs: stats(values.map((value) => value.firstDestinationObservedMs)),
+ firstCorrectObservedMs: stats(values.map((value) => value.firstCorrectObservedMs)),
+ stableObservedMs: stats(values.map((value) => value.stableObservedMs)),
+ },
+ ]),
+ )
+}
+
+async function switchSession(page: Page, sessionID: string, title: string) {
+ const href = stressSessionHref(sessionID)
+ const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
+ await expect(tab).toBeVisible()
+ await tab.click()
+ await expectSessionTitle(page, title)
+}
diff --git a/packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts b/packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts
new file mode 100644
index 00000000000..e315c2ad43b
--- /dev/null
+++ b/packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts
@@ -0,0 +1,46 @@
+export type SessionSwitchSample = {
+ observedAtMs: number
+ destination: string[]
+ source: string[]
+ hasVisibleRows: boolean
+ last: boolean
+ bottomErrorPx?: number
+}
+
+export function classifySessionSwitch(samples: SessionSwitchSample[]) {
+ const firstDestination = samples.findIndex((sample) => sample.destination.length > 0)
+ const firstCorrect = samples.findIndex(isCorrectDestination)
+ const stable = samples.findIndex((_, index) => isStableSessionSwitch(samples.slice(index, index + 3)))
+ return {
+ firstDestinationObservedMs: samples[firstDestination]?.observedAtMs ?? null,
+ firstCorrectObservedMs: samples[firstCorrect]?.observedAtMs ?? null,
+ stableObservedMs: samples[stable + 2]?.observedAtMs ?? null,
+ wrongDestinationSamples: samples
+ .slice(firstDestination)
+ .filter((sample) => sample.destination.length > 0 && !sample.last).length,
+ blankSamples: samples.filter((sample) => !sample.hasVisibleRows).length,
+ unknownSamples: samples.filter(
+ (sample) => sample.hasVisibleRows && sample.destination.length === 0 && sample.source.length === 0,
+ ).length,
+ sourceSamples: samples.filter((sample) => sample.source.length > 0).length,
+ }
+}
+
+export function isCorrectDestination(sample: SessionSwitchSample) {
+ return (
+ sample.destination.length > 0 &&
+ sample.source.length === 0 &&
+ sample.last &&
+ Math.abs(sample.bottomErrorPx ?? Infinity) <= 1
+ )
+}
+
+export function isStableSessionSwitch(samples: SessionSwitchSample[]) {
+ return samples.length === 3 && samples.every(isCorrectDestination)
+}
+
+export function isStableDestination(samples: Pick[]) {
+ return (
+ samples.length === 3 && samples.every((sample) => sample.last && Math.abs(sample.bottomErrorPx ?? Infinity) <= 1)
+ )
+}
diff --git a/packages/app/e2e/performance/timeline/session-tab-switch-probe.ts b/packages/app/e2e/performance/timeline/session-tab-switch-probe.ts
new file mode 100644
index 00000000000..14f9d2d003e
--- /dev/null
+++ b/packages/app/e2e/performance/timeline/session-tab-switch-probe.ts
@@ -0,0 +1,152 @@
+import { expect, type Page } from "@playwright/test"
+import { classifySessionSwitch, isStableDestination, type SessionSwitchSample } from "./session-tab-switch-metrics"
+
+type SessionSwitchProbe = {
+ samples: SessionSwitchSample[]
+ stop: () => void
+}
+
+async function installSessionSwitchProbe(
+ page: Page,
+ input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string },
+) {
+ await page.evaluate(({ destinationIDs, sourceIDs, lastID, href }) => {
+ const destination = new Set(destinationIDs)
+ const source = new Set(sourceIDs)
+ const samples: SessionSwitchSample[] = []
+ let started: number | undefined
+ let running = true
+ const sample = () => {
+ if (!running || started === undefined) return
+ setTimeout(() => {
+ if (!running || started === undefined) return
+ const observedAtMs = performance.now() - started
+ const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) =>
+ element.querySelector("[data-timeline-row]"),
+ )
+ if (root) {
+ const view = root.getBoundingClientRect()
+ const visible = [...root.querySelectorAll("[data-message-id]")]
+ .filter((element) => {
+ const rect = element.getBoundingClientRect()
+ return rect.bottom > view.top && rect.top < view.bottom
+ })
+ .map((element) => element.dataset.messageId!)
+ const hasVisibleRows = [...root.querySelectorAll("[data-timeline-key]")].some((element) => {
+ const rect = element.getBoundingClientRect()
+ return rect.bottom > view.top && rect.top < view.bottom
+ })
+ const spacer = root.querySelector('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
+ samples.push({
+ observedAtMs,
+ destination: visible.filter((id) => destination.has(id)),
+ source: visible.filter((id) => source.has(id)),
+ hasVisibleRows,
+ last: visible.includes(lastID),
+ bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
+ })
+ } else {
+ samples.push({ observedAtMs, destination: [], source: [], hasVisibleRows: false, last: false })
+ }
+ requestAnimationFrame(sample)
+ }, 0)
+ }
+ document.addEventListener(
+ "click",
+ (event) => {
+ const link = event.target instanceof Element ? event.target.closest("a") : undefined
+ if (link?.getAttribute("href") !== href) return
+ started = performance.now()
+ requestAnimationFrame(sample)
+ },
+ { capture: true, once: true },
+ )
+ ;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe = {
+ samples,
+ stop: () => {
+ running = false
+ },
+ }
+ }, input)
+}
+
+async function waitForStableSessionSwitch(page: Page) {
+ await page.waitForFunction(() => {
+ const samples = (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.samples
+ if (!samples) return false
+ return samples.some((_, index) => {
+ const stable = samples.slice(index, index + 3)
+ return (
+ stable.length === 3 &&
+ stable.every(
+ (sample) =>
+ sample.destination.length > 0 &&
+ sample.source.length === 0 &&
+ sample.last &&
+ Math.abs(sample.bottomErrorPx ?? Infinity) <= 1,
+ )
+ )
+ })
+ })
+}
+
+async function collectSessionSwitchResult(page: Page) {
+ const samples = await page.evaluate(() => {
+ const probe = (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe!
+ probe.stop()
+ return probe.samples
+ })
+ return classifySessionSwitch(samples)
+}
+
+export async function measureSessionSwitch(
+ page: Page,
+ input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string; switch: () => Promise },
+) {
+ const { switch: run, ...probe } = input
+ await installSessionSwitchProbe(page, probe)
+ await run()
+ await waitForStableSessionSwitch(page)
+ return collectSessionSwitchResult(page)
+}
+
+export async function waitForStableTimeline(page: Page, lastID: string) {
+ const samples: Pick[] = []
+ await expect
+ .poll(
+ async () => {
+ samples.push(
+ await page.evaluate(
+ (lastID) =>
+ new Promise>((resolve) => {
+ requestAnimationFrame(() =>
+ setTimeout(() => {
+ const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) =>
+ element.querySelector("[data-timeline-row]"),
+ )
+ if (!root) {
+ resolve({ last: false })
+ return
+ }
+ const view = root.getBoundingClientRect()
+ const last = [...root.querySelectorAll("[data-message-id]")].some((element) => {
+ if (element.dataset.messageId !== lastID) return false
+ const rect = element.getBoundingClientRect()
+ return rect.bottom > view.top && rect.top < view.bottom
+ })
+ const spacer = root
+ .querySelector('[data-timeline-row="bottom-spacer"]')
+ ?.getBoundingClientRect()
+ resolve({ last, bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined })
+ }, 0),
+ )
+ }),
+ lastID,
+ ),
+ )
+ return isStableDestination(samples.slice(-3))
+ },
+ { timeout: 30_000, intervals: [0] },
+ )
+ .toBe(true)
+}
diff --git a/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts b/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts
new file mode 100644
index 00000000000..6353416d50c
--- /dev/null
+++ b/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts
@@ -0,0 +1,488 @@
+import { base64Encode } from "@opencode-ai/core/util/encode"
+import type { Page } from "@playwright/test"
+import { mockOpenCodeServer } from "../../utils/mock-server"
+import { expectAppVisible, expectSessionTitle } from "../../utils/waits"
+import { expect } from "../benchmark"
+
+const directory = "C:/OpenCode/TimelineStateRegression"
+const projectID = "proj_timeline_state_regression"
+const sessionID = "ses_timeline_state_regression"
+const userMessageID = "msg_user_regression"
+const assistantMessageID = "msg_assistant_regression"
+const editPartID = "prt_0001_edit"
+export const textPartID = "prt_9999_text"
+const title = "Timeline collapse state regression"
+const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
+
+type EventPayload = {
+ directory: string
+ payload: Record
+}
+
+const userMessage = {
+ info: {
+ id: userMessageID,
+ sessionID,
+ role: "user",
+ time: { created: 1700000000000 },
+ summary: { diffs: [] },
+ agent: "build",
+ model,
+ },
+ parts: [
+ {
+ id: "prt_user_text",
+ sessionID,
+ messageID: userMessageID,
+ type: "text",
+ text: "Please edit the file.",
+ },
+ ],
+}
+
+const editPart = {
+ id: editPartID,
+ sessionID,
+ messageID: assistantMessageID,
+ type: "tool",
+ callID: "call_edit_regression",
+ tool: "edit",
+ state: {
+ status: "completed",
+ input: { filePath: "src/regression.ts" },
+ output: "Edited src/regression.ts",
+ title: "src/regression.ts",
+ metadata: {
+ filediff: {
+ file: "src/regression.ts",
+ additions: 1,
+ deletions: 1,
+ before: "export const value = 'before'\n",
+ after: "export const value = 'after'\n",
+ },
+ diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n",
+ },
+ time: { start: 1700000001000, end: 1700000002000 },
+ },
+}
+
+const streamedTextPart = {
+ id: textPartID,
+ sessionID,
+ messageID: assistantMessageID,
+ type: "text",
+ text: "Streaming added a later assistant text part.",
+}
+
+const assistantMessage = {
+ info: {
+ id: assistantMessageID,
+ sessionID,
+ role: "assistant",
+ time: { created: 1700000001000 },
+ parentID: userMessageID,
+ modelID: model.modelID,
+ providerID: model.providerID,
+ mode: "build",
+ agent: "build",
+ path: { cwd: directory, root: directory },
+ cost: 0.01,
+ tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
+ variant: "max",
+ },
+ parts: [editPart],
+}
+
+export async function setupTimelineBenchmark(page: Page, options: { historyTurns: number; eventBatch: number }) {
+ const events: EventPayload[] = []
+ let eventBatch = options.eventBatch
+ await mockOpenCodeServer(page, {
+ directory,
+ project: project(),
+ provider: provider(),
+ sessions: [session()],
+ pageMessages: () => ({
+ items: [
+ ...Array.from({ length: options.historyTurns }, (_, index) => performanceTurn(index)).flat(),
+ userMessage,
+ assistantMessage,
+ ],
+ }),
+ events: () => events.splice(0, eventBatch),
+ eventRetry: 16,
+ })
+ await page.addInitScript(() => {
+ localStorage.setItem(
+ "settings.v3",
+ JSON.stringify({
+ general: {
+ editToolPartsExpanded: true,
+ shellToolPartsExpanded: true,
+ showReasoningSummaries: true,
+ showSessionProgressBar: true,
+ },
+ }),
+ )
+ })
+ await page.setViewportSize({ width: 1366, height: 768 })
+ const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
+ const text = page.locator(`[data-timeline-part-id="${textPartID}"]`).first()
+ await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
+ await expectSessionTitle(page, title)
+ await expectAppVisible(scroller)
+ return {
+ scroller,
+ text,
+ transport: {
+ enqueue(payload: EventPayload | EventPayload[]) {
+ events.push(...(Array.isArray(payload) ? payload : [payload]))
+ },
+ pendingCount() {
+ return events.length
+ },
+ releaseAll() {
+ eventBatch = events.length
+ },
+ },
+ async scrollToBottom() {
+ await scroller.evaluate((element) => {
+ element.scrollTop = element.scrollHeight
+ })
+ },
+ async waitForStableGeometry() {
+ await expect
+ .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
+ .toBeLessThanOrEqual(1)
+ await page.waitForFunction((partID) => {
+ const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) =>
+ element.querySelector(`[data-timeline-part-id="${partID}"]`),
+ )
+ if (!root) return false
+ return new Promise((resolve) => {
+ const height = root.scrollHeight
+ requestAnimationFrame(() =>
+ requestAnimationFrame(() =>
+ resolve(root.scrollHeight === height && root.scrollHeight - root.clientHeight - root.scrollTop <= 1),
+ ),
+ )
+ })
+ }, textPartID)
+ },
+ }
+}
+
+export function buildInitialStreamEvent(deltaCount: number): EventPayload {
+ return {
+ directory,
+ payload: {
+ type: "message.part.updated",
+ properties: {
+ part: {
+ ...streamedTextPart,
+ text: `Streaming${streamChunk(0, deltaCount + 1)}\n\n\`\`\`ts\nconst initial = true\n\`\`\``,
+ },
+ },
+ },
+ }
+}
+
+export function buildStreamDeltaEvents(deltaCount: number): EventPayload[] {
+ return Array.from({ length: deltaCount }, (_, index) => ({
+ directory,
+ payload: {
+ type: "message.part.delta",
+ properties: {
+ messageID: assistantMessageID,
+ partID: textPartID,
+ field: "text",
+ delta: streamChunk(index + 1, deltaCount + 1),
+ },
+ },
+ }))
+}
+
+function performanceTurn(index: number) {
+ const suffix = String(index).padStart(4, "0")
+ const userID = `msg_0000_${suffix}_a_user`
+ const assistantID = `msg_0000_${suffix}_b_assistant`
+ const before = historicalSource(index, false)
+ const after = historicalSource(index, true)
+ const parts = [
+ ...(index % 5 === 0
+ ? [
+ {
+ id: `prt_0000_${suffix}_reasoning`,
+ sessionID,
+ messageID: assistantID,
+ type: "reasoning",
+ text: `Reviewing the existing implementation. ${"constraint analysis ".repeat(20)}`,
+ time: { start: 1690000001000 + index * 2_000, end: 1690000001200 + index * 2_000 },
+ },
+ ]
+ : []),
+ {
+ id: `prt_0000_${suffix}_assistant`,
+ sessionID,
+ messageID: assistantID,
+ type: "text",
+ text: historicalMarkdown(index),
+ },
+ ...(index % 8 === 0
+ ? [
+ {
+ id: `prt_0000_${suffix}_edit`,
+ sessionID,
+ messageID: assistantID,
+ type: "tool",
+ callID: `call_0000_${suffix}_edit`,
+ tool: "edit",
+ state: {
+ status: "completed",
+ input: { filePath: `src/history-${index}.ts` },
+ output: `Edited src/history-${index}.ts`,
+ title: `src/history-${index}.ts`,
+ metadata: {
+ filediff: { file: `src/history-${index}.ts`, additions: 48, deletions: 48, before, after },
+ },
+ time: { start: 1690000001200 + index * 2_000, end: 1690000001400 + index * 2_000 },
+ },
+ },
+ ]
+ : []),
+ ...(index % 12 === 0
+ ? [
+ {
+ id: `prt_0000_${suffix}_write`,
+ sessionID,
+ messageID: assistantID,
+ type: "tool",
+ callID: `call_0000_${suffix}_write`,
+ tool: "write",
+ state: {
+ status: "completed",
+ input: { filePath: `src/generated-${index}.tsx`, content: after },
+ output: `Wrote src/generated-${index}.tsx`,
+ title: `src/generated-${index}.tsx`,
+ metadata: {
+ filediff: { file: `src/generated-${index}.tsx`, additions: 32, deletions: 0, before: "", after },
+ },
+ time: { start: 1690000001400 + index * 2_000, end: 1690000001500 + index * 2_000 },
+ },
+ },
+ ]
+ : []),
+ ...(index % 16 === 0
+ ? [
+ {
+ id: `prt_0000_${suffix}_patch`,
+ sessionID,
+ messageID: assistantID,
+ type: "tool",
+ callID: `call_0000_${suffix}_patch`,
+ tool: "apply_patch",
+ state: {
+ status: "completed",
+ input: { patchText: realisticPatch(index) },
+ output: "Success. Updated src/components/SessionCard.tsx",
+ title: "src/components/SessionCard.tsx",
+ metadata: {
+ files: [
+ {
+ filePath: "src/components/SessionCard.tsx",
+ relativePath: "src/components/SessionCard.tsx",
+ type: "update",
+ additions: 8,
+ deletions: 3,
+ patch: realisticPatch(index),
+ before,
+ after,
+ },
+ ],
+ },
+ time: { start: 1690000001500 + index * 2_000, end: 1690000001700 + index * 2_000 },
+ },
+ },
+ ]
+ : []),
+ ]
+ return [
+ {
+ info: {
+ id: userID,
+ sessionID,
+ role: "user",
+ time: { created: 1690000000000 + index * 2_000 },
+ summary: { diffs: [] },
+ agent: "build",
+ model,
+ },
+ parts: [
+ {
+ id: `prt_0000_${suffix}_user`,
+ sessionID,
+ messageID: userID,
+ type: "text",
+ text: `Historical prompt ${index}`,
+ },
+ ],
+ },
+ {
+ info: {
+ id: assistantID,
+ sessionID,
+ role: "assistant",
+ time: { created: 1690000001000 + index * 2_000, completed: 1690000001500 + index * 2_000 },
+ parentID: userID,
+ modelID: model.modelID,
+ providerID: model.providerID,
+ mode: "build",
+ agent: "build",
+ path: { cwd: directory, root: directory },
+ cost: 0.01,
+ tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
+ variant: "max",
+ finish: "stop",
+ },
+ parts,
+ },
+ ]
+}
+
+function historicalMarkdown(index: number) {
+ const code = `import { For, Show, createSignal } from "solid-js"
+
+type SessionRow = { id: string; title: string; active: boolean }
+
+export function SessionList(props: { rows: SessionRow[] }) {
+ const [selected, setSelected] = createSignal()
+ return (
+
+ {(row) => (
+
+ )}
+
+ )
+}`
+ return `## Session renderer review ${index}
+
+The active session keeps **semantic row identity** while reconciling measured content. See [Solid documentation](https://docs.solidjs.com/) and the inline \`measureElement(node)\` call.
+
+| Concern | Current behavior | Verification |
+| --- | --- | --- |
+| streaming | appends Markdown blocks | painted frames |
+| geometry | anchors visible rows | DOM coordinates |
+| tools | preserves expanded state | keyed remount probe |
+
+> Long sessions combine Markdown, syntax highlighting, tool output, and asynchronously rendered diffs.
+
+${index % 4 === 0 ? `\`\`\`tsx\n${code}\n\`\`\`\n\n\`\`\`bash\nbun typecheck\nbun test --preload ./happydom.ts ./src/pages/session\ngit diff --check\n\`\`\`` : "- preserve the viewport anchor\n- avoid replacing stable Markdown nodes\n- process provider deltas without blocking input"}`
+}
+
+function historicalSource(index: number, updated: boolean) {
+ const method = updated ? "toLocaleUpperCase(props.locale)" : "toUpperCase()"
+ const limit = updated ? 24 : 20
+ return `import { createMemo, For } from "solid-js"
+
+type Message = {
+ id: string
+ role: "user" | "assistant"
+ text: string
+ tokens: { input: number; output: number }
+}
+
+export function MessageSummary(props: { messages: Message[]; locale: string }) {
+ const visible = createMemo(() => props.messages.filter((message) => message.text.trim()).slice(-${limit}))
+ const total = createMemo(() => visible().reduce((sum, message) => sum + message.tokens.output, 0))
+ return (
+
+ {total().toLocaleString(props.locale)} output tokens
+ {(message) => {message.text.${method}}
}
+
+ )
+}
+`
+}
+
+function realisticPatch(index: number) {
+ return `*** Begin Patch
+*** Update File: src/components/SessionCard.tsx
+@@
+-const title = props.session.title.toUpperCase()
+-const messages = props.messages.slice(-20)
++const title = props.session.title.toLocaleUpperCase(props.locale)
++const messages = props.messages.filter((message) => message.text.trim()).slice(-24)
++const outputTokens = messages.reduce((sum, message) => sum + message.tokens.output, 0)
+@@
+- {title}
++ {title}
++ {outputTokens.toLocaleString(props.locale)} output tokens
+*** End Patch`
+}
+
+export function streamChunk(index: number, count: number) {
+ if (index === 0) return `\n\n## Implementation plan\n\nStreaming **bold analysis`
+ if (index === count - 1)
+ return `\n\`\`\`\n\n## Verification\n\n- **Typecheck:** passed\n- **Timeline geometry:** stable\n- **Streaming output:** benchmark-complete `
+
+ const section = Math.floor(index / 18) + 1
+ const fragments = [
+ ` continues across three`,
+ ` or four word`,
+ ` provider deltas and`,
+ ` closes in this fragment**. \n\n`,
+ `| Concern | State`,
+ ` | Verification |\n|`,
+ ` --- | ---`,
+ ` | --- |\n|`,
+ ` markdown | incremental |`,
+ ` painted frames | \n\n`,
+ `\`\`\`tsx\nconst row: SessionRow`,
+ ` = rows[index] ??`,
+ ` fallback\nconst title =`,
+ ` row.title.toLocaleUpperCase(locale)\n`,
+ `const selected = createMemo(()`,
+ ` => row.id ===`,
+ ` activeID()) // stream-${index}\n`,
+ `// stream-${index}\n\`\`\`\n\n### Iteration ${section}\n\nStreaming **bold analysis`,
+ ]
+ return fragments[(index - 1) % fragments.length]!
+}
+
+function project() {
+ return {
+ id: projectID,
+ worktree: directory,
+ vcs: "git",
+ name: "timeline-state-regression",
+ time: { created: 1700000000000, updated: 1700000000000 },
+ sandboxes: [],
+ }
+}
+
+function session() {
+ return {
+ id: sessionID,
+ slug: "timeline-state-regression",
+ projectID,
+ directory,
+ title,
+ version: "dev",
+ time: { created: 1700000000000, updated: 1700000000000 },
+ }
+}
+
+function provider() {
+ return {
+ all: [
+ {
+ id: "opencode",
+ name: "OpenCode",
+ models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
+ },
+ ],
+ connected: ["opencode"],
+ default: { providerID: "opencode", modelID: "claude-opus-4-6" },
+ }
+}
diff --git a/packages/app/e2e/performance/timeline/session-timeline-benchmark.spec.ts b/packages/app/e2e/performance/timeline/session-timeline-benchmark.spec.ts
new file mode 100644
index 00000000000..64d79283f06
--- /dev/null
+++ b/packages/app/e2e/performance/timeline/session-timeline-benchmark.spec.ts
@@ -0,0 +1,85 @@
+import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
+import {
+ buildInitialStreamEvent,
+ buildStreamDeltaEvents,
+ setupTimelineBenchmark,
+ textPartID,
+} from "./session-timeline-benchmark.fixture"
+import { startTimelineProfile } from "./session-timeline-profile"
+import {
+ collectTimelineStreamMetrics,
+ installTimelineStreamProbe,
+ startTimelineStreamProbe,
+} from "./session-timeline-stream-probe"
+
+benchmark.describe("performance: session timeline streaming", () => {
+ benchmark("streams assistant text without remounting or oscillating", async ({ page, report }) => {
+ benchmark.setTimeout(480_000)
+ const cpuThrottle = Number(process.env.TIMELINE_CPU_THROTTLE ?? 30)
+ const deltaCount = Number(process.env.TIMELINE_DELTA_COUNT ?? 160)
+ const historyTurns = Number(process.env.TIMELINE_HISTORY_TURNS ?? 320)
+ const eventBatch = Number(process.env.TIMELINE_EVENT_BATCH ?? 1)
+ const minimal = process.env.TIMELINE_MINIMAL === "1"
+ const profileCPU = process.env.TIMELINE_CPU_PROFILE === "1"
+ const profileVisual = !minimal && profileCPU && process.env.TIMELINE_VISUAL_PROFILE !== "0"
+ const fixture = await setupTimelineBenchmark(page, {
+ historyTurns,
+ eventBatch,
+ })
+
+ fixture.transport.enqueue(buildInitialStreamEvent(deltaCount))
+ const contentStart = performance.now()
+ await expect(fixture.text).toBeVisible()
+ await expect(fixture.text).toContainText("Implementation plan")
+ const initialContentObservedMs = performance.now() - contentStart
+ await fixture.scrollToBottom()
+ await fixture.waitForStableGeometry()
+
+ const profile = await startTimelineProfile(page, { cpuThrottle, profileCPU })
+ await installTimelineStreamProbe(page, { textPartID, finalIndex: deltaCount, profileVisual, minimal })
+ const deltas = buildStreamDeltaEvents(deltaCount)
+ await startTimelineStreamProbe(page)
+ fixture.transport.enqueue(deltas)
+
+ await page.waitForFunction(
+ (finalIndex) =>
+ (
+ window as Window & {
+ __timelineStreamBenchmark?: { applied: { index: number }[] }
+ }
+ ).__timelineStreamBenchmark?.applied.some((value) => value.index === finalIndex),
+ deltaCount,
+ { timeout: 420_000 },
+ )
+ await expect(fixture.text).toContainText("benchmark-complete")
+ await expect(fixture.text).toContainText("Streaming")
+ await fixture.waitForStableGeometry()
+ const metrics = await collectTimelineStreamMetrics(page, {
+ textPartID,
+ finalIndex: deltaCount,
+ navigations: benchmarkDiagnostics(page).navigations,
+ })
+ const delivered = deltas.length - fixture.transport.pendingCount()
+ await profile.stop()
+
+ report(
+ {
+ endToEndInitialContentObservedMs: initialContentObservedMs,
+ ...metrics,
+ deliveredDeltas: delivered,
+ pendingDeltas: fixture.transport.pendingCount(),
+ },
+ {
+ cpuThrottle,
+ profileCPU,
+ profileVisual,
+ minimal,
+ queuedDeltas: deltas.length,
+ historyTurns,
+ eventBatch,
+ },
+ )
+
+ await profile.reset()
+ })
+})
diff --git a/packages/app/e2e/performance/timeline/session-timeline-profile.ts b/packages/app/e2e/performance/timeline/session-timeline-profile.ts
new file mode 100644
index 00000000000..e1689498c19
--- /dev/null
+++ b/packages/app/e2e/performance/timeline/session-timeline-profile.ts
@@ -0,0 +1,40 @@
+import type { CDPSession, Page } from "@playwright/test"
+
+export async function startTimelineProfile(page: Page, options: { cpuThrottle: number; profileCPU: boolean }) {
+ const cdp = await page.context().newCDPSession(page)
+ if (options.cpuThrottle > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: options.cpuThrottle })
+ if (options.profileCPU) {
+ await cdp.send("Profiler.enable")
+ await cdp.send("Profiler.setSamplingInterval", { interval: 100 })
+ await cdp.send("Profiler.start")
+ }
+ return {
+ async stop() {
+ if (!options.profileCPU) return
+ const result = await cdp.send("Profiler.stop")
+ const self = new Map()
+ result.profile.samples?.forEach((id, index) => {
+ const duration = (result.profile.timeDeltas?.[index] ?? 0) / 1_000
+ self.set(id, (self.get(id) ?? 0) + duration)
+ })
+ console.log(
+ "timeline cpu profile",
+ JSON.stringify(
+ result.profile.nodes
+ .map((node) => ({
+ function: node.callFrame.functionName || "(anonymous)",
+ url: node.callFrame.url,
+ line: node.callFrame.lineNumber + 1,
+ selfMs: self.get(node.id) ?? 0,
+ }))
+ .filter((node) => node.selfMs > 1)
+ .sort((a, b) => b.selfMs - a.selfMs)
+ .slice(0, 40),
+ ),
+ )
+ },
+ async reset() {
+ if (options.cpuThrottle > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: 1 })
+ },
+ }
+}
diff --git a/packages/app/e2e/performance/timeline/session-timeline-stream-probe.ts b/packages/app/e2e/performance/timeline/session-timeline-stream-probe.ts
new file mode 100644
index 00000000000..a3cd698cde4
--- /dev/null
+++ b/packages/app/e2e/performance/timeline/session-timeline-stream-probe.ts
@@ -0,0 +1,547 @@
+import type { Page } from "@playwright/test"
+
+const STREAM_MARKER_PATTERN = "stream-(\\d+)"
+const STREAM_FRAGMENT_COUNT = 18
+
+type TimelineProbeState = {
+ started: number
+ ended: number
+ profileVisual: boolean
+ minimal: boolean
+ frames: number[]
+ frameAt: number[]
+ applied: { at: number; index: number }[]
+ geometry: {
+ scrollTop: number
+ scrollHeight: number
+ clientHeight: number
+ distance: number
+ virtualHeight: number
+ headerHeight: number
+ }[]
+ blanks: number
+ longTasks: number[]
+ layoutShifts: number[]
+ visibleMounts: number
+ visibleUnmounts: number
+ visibleRows: Set
+ visibleSubtreeMounts: string[]
+ visibleSubtreeUnmounts: string[]
+ visibleSubtreeReplacements: number
+ visibleSubtreeDropouts: string[]
+ visibleSubtrees: Map
+ subtreeKeys: WeakMap
+ maxOverlap: number
+ maxGap: number
+ maxPartTopMovement: number
+ previousPartTop: number
+ slowFrames: {
+ duration: number
+ index: number
+ phase: "stream" | "boundary" | "complete" | "unknown"
+ tokenSpans: number
+ blocks: number
+ codeBlocks: number
+ height: number
+ distance: number
+ }[]
+ scroll: {
+ calls: number
+ callNoops: number
+ sameFrameCalls: number
+ assignments: number
+ assignmentNoops: number
+ lastCallFrame: number
+ frame: number
+ }
+ row: HTMLElement
+ markdown: HTMLElement
+ running: boolean
+ previous: number
+ cleanup: () => void
+ start: () => void
+}
+
+export async function installTimelineStreamProbe(
+ page: Page,
+ options: { textPartID: string; finalIndex: number; profileVisual: boolean; minimal: boolean },
+) {
+ await page.evaluate(
+ ({ textPartID, finalIndex, profileVisual, minimal, markerPattern, fragmentCount }) => {
+ const part = document.querySelector(`[data-timeline-part-id="${textPartID}"]`)
+ const row = part?.closest("[data-timeline-row]")
+ const markdown = part?.querySelector('[data-component="markdown"]')
+ const root = part?.closest(".scroll-view__viewport")
+ if (!part || !row || !markdown || !root) throw new Error("missing streaming benchmark nodes")
+ const viewport = root.getBoundingClientRect()
+ const state: TimelineProbeState = {
+ started: 0,
+ ended: Infinity,
+ profileVisual,
+ minimal,
+ frames: [],
+ frameAt: [],
+ applied: [],
+ geometry: [],
+ blanks: 0,
+ longTasks: [],
+ layoutShifts: [],
+ visibleMounts: 0,
+ visibleUnmounts: 0,
+ visibleRows: new Set(
+ [...root.querySelectorAll("[data-timeline-key]")].filter((element) => {
+ const rect = element.getBoundingClientRect()
+ return rect.bottom > viewport.top && rect.top < viewport.bottom
+ }),
+ ),
+ visibleSubtreeMounts: [],
+ visibleSubtreeUnmounts: [],
+ visibleSubtreeReplacements: 0,
+ visibleSubtreeDropouts: [],
+ visibleSubtrees: new Map(),
+ subtreeKeys: new WeakMap(),
+ maxOverlap: 0,
+ maxGap: 0,
+ maxPartTopMovement: 0,
+ previousPartTop: part.getBoundingClientRect().top,
+ slowFrames: [],
+ scroll: {
+ calls: 0,
+ callNoops: 0,
+ sameFrameCalls: 0,
+ assignments: 0,
+ assignmentNoops: 0,
+ lastCallFrame: -1,
+ frame: 0,
+ },
+ row,
+ markdown,
+ running: false,
+ previous: 0,
+ cleanup: () => {},
+ start: () => {},
+ }
+ ;(window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark = state
+ const scrollTo = Element.prototype.scrollTo
+ const scrollTop = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop")!
+ if (profileVisual) {
+ Element.prototype.scrollTo = function (...args) {
+ state.scroll.calls += 1
+ const top = typeof args[0] === "object" ? args[0]?.top : args[1]
+ if (typeof top === "number") {
+ const target = Math.min(top, this.scrollHeight - this.clientHeight)
+ if (Math.abs(this.scrollTop - target) < 1) state.scroll.callNoops += 1
+ }
+ if (state.scroll.lastCallFrame === state.scroll.frame) state.scroll.sameFrameCalls += 1
+ state.scroll.lastCallFrame = state.scroll.frame
+ return scrollTo.apply(this, args)
+ }
+ Object.defineProperty(Element.prototype, "scrollTop", {
+ configurable: true,
+ get: scrollTop.get,
+ set(value) {
+ state.scroll.assignments += 1
+ if (Math.abs(this.scrollTop - value) < 1) state.scroll.assignmentNoops += 1
+ scrollTop.set!.call(this, value)
+ },
+ })
+ }
+
+ const recordLongTasks = (entries: PerformanceEntry[]) => {
+ if (!state.running) return
+ state.longTasks.push(
+ ...entries
+ .filter((entry) => entry.startTime >= state.started && entry.startTime <= state.ended)
+ .map((entry) => entry.duration),
+ )
+ }
+ const longTaskObserver = new PerformanceObserver((list) => recordLongTasks(list.getEntries()))
+ longTaskObserver.observe({ type: "longtask" })
+ const recordLayoutShifts = (entries: PerformanceEntry[]) => {
+ if (!state.running) return
+ state.layoutShifts.push(
+ ...entries
+ .map((entry) => {
+ const shift = entry as LayoutShiftEntry
+ if (shift.startTime < state.started || shift.hadRecentInput) return
+ return shift.value
+ })
+ .filter((value): value is number => value !== undefined),
+ )
+ }
+ const layoutShiftObserver = profileVisual
+ ? new PerformanceObserver((list) => recordLayoutShifts(list.getEntries()))
+ : undefined
+ layoutShiftObserver?.observe({ type: "layout-shift", buffered: true })
+
+ const visible = (element: Element) => {
+ const rect = element.getBoundingClientRect()
+ const viewport = root.getBoundingClientRect()
+ const style = getComputedStyle(element)
+ return (
+ element.isConnected &&
+ rect.width > 0 &&
+ rect.height > 0 &&
+ rect.bottom > viewport.top &&
+ rect.top < viewport.bottom &&
+ style.display !== "none" &&
+ style.visibility !== "hidden" &&
+ Number(style.opacity) > 0
+ )
+ }
+ const critical = [
+ "[data-timeline-part-id]",
+ '[data-component="edit-content"]',
+ '[data-component="apply-patch-file-diff"]',
+ '[data-component="file"]',
+ '[data-component="markdown-code"]',
+ "[data-markdown-block]",
+ ].join(",")
+ const describe = (element: Element) => {
+ const cached = state.subtreeKeys.get(element)
+ if (!element.isConnected && cached) return cached
+ const part = element.closest("[data-timeline-part-id]")?.dataset.timelinePartId ?? "unknown"
+ const block = element
+ .closest("[data-markdown-key]")
+ ?.dataset.markdownKey?.replace(/:(?:code|full|live)$/, "")
+ const component =
+ element.getAttribute("data-component") ?? element.getAttribute("data-markdown-block") ?? element.tagName
+ const key = `${part}:${block ?? "root"}:${component}`
+ state.subtreeKeys.set(element, key)
+ return key
+ }
+ const recordMutations = (records: MutationRecord[]) => {
+ if (!state.running) return
+ records.forEach((record) => {
+ record.addedNodes.forEach((node) => {
+ if (node instanceof HTMLElement && node.matches("[data-timeline-key]") && visible(node)) {
+ state.visibleMounts += 1
+ state.visibleRows.add(node)
+ }
+ if (!(node instanceof Element)) return
+ const added = [node, ...node.querySelectorAll(critical)].filter((element) => element.matches(critical))
+ added.forEach((element) => {
+ if (visible(element)) state.visibleSubtreeMounts.push(describe(element))
+ })
+ })
+ record.removedNodes.forEach((node) => {
+ if (node instanceof HTMLElement && node.matches("[data-timeline-key]") && state.visibleRows.delete(node))
+ state.visibleUnmounts += 1
+ if (!(node instanceof Element)) return
+ const removed = [node, ...node.querySelectorAll(critical)].filter((element) => element.matches(critical))
+ removed.forEach((element) => {
+ const key = describe(element)
+ if (state.visibleSubtrees.get(key) === element) state.visibleSubtreeUnmounts.push(key)
+ })
+ })
+ })
+ }
+ const mutationObserver = profileVisual ? new MutationObserver(recordMutations) : undefined
+ mutationObserver?.observe(root, { childList: true, subtree: true })
+ const currentPart = () => root.querySelector(`[data-timeline-part-id="${textPartID}"]`)
+ const observeProgress = (at: number) => {
+ if (!state.running) return
+ const content = currentPart()?.textContent ?? ""
+ const index = content.includes("benchmark-complete")
+ ? finalIndex
+ : Number(content.match(new RegExp(markerPattern, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1)
+ if (index >= 0 && index !== state.applied.at(-1)?.index) state.applied.push({ at, index })
+ }
+ const progressObserver = new MutationObserver(() => observeProgress(performance.now()))
+ progressObserver.observe(root, { characterData: true, childList: true, subtree: true })
+ state.cleanup = () => {
+ recordLongTasks(longTaskObserver.takeRecords())
+ recordLayoutShifts(layoutShiftObserver?.takeRecords() ?? [])
+ recordMutations(mutationObserver?.takeRecords() ?? [])
+ if (progressObserver.takeRecords().length) observeProgress(performance.now())
+ longTaskObserver.disconnect()
+ layoutShiftObserver?.disconnect()
+ mutationObserver?.disconnect()
+ progressObserver.disconnect()
+ if (!profileVisual) return
+ Element.prototype.scrollTo = scrollTo
+ Object.defineProperty(Element.prototype, "scrollTop", scrollTop)
+ }
+
+ const sample = (now: number) => {
+ if (!state.running) return
+ state.frameAt.push(now)
+ observeProgress(now)
+ if (minimal) {
+ state.frames.push(now - state.previous)
+ state.previous = now
+ requestAnimationFrame(sample)
+ return
+ }
+ setTimeout(() => {
+ if (!state.running) return
+ state.scroll.frame += 1
+ const duration = now - state.previous
+ state.frames.push(duration)
+ state.previous = now
+ const virtualRoot = root.querySelector("[data-timeline-virtual-content]")
+ const header = root.querySelector("[data-session-title]")
+ state.geometry.push({
+ scrollTop: root.scrollTop,
+ scrollHeight: root.scrollHeight,
+ clientHeight: root.clientHeight,
+ distance: root.scrollHeight - root.clientHeight - root.scrollTop,
+ virtualHeight: virtualRoot?.getBoundingClientRect().height ?? 0,
+ headerHeight: header?.getBoundingClientRect().height ?? 0,
+ })
+ const viewport = root.getBoundingClientRect()
+ if (profileVisual) {
+ const visibleRows = [...root.querySelectorAll("[data-timeline-key]")]
+ .map((element) => ({ element, rect: element.getBoundingClientRect() }))
+ .filter((item) => item.rect.bottom > viewport.top && item.rect.top < viewport.bottom)
+ .sort((a, b) => a.rect.top - b.rect.top)
+ state.visibleRows = new Set(visibleRows.map((item) => item.element))
+ const rows = visibleRows.map((item) => item.rect)
+ rows.slice(1).forEach((rect, index) => {
+ const previous = rows[index]!
+ state.maxOverlap = Math.max(state.maxOverlap, previous.bottom - rect.top)
+ state.maxGap = Math.max(state.maxGap, rect.top - previous.bottom)
+ })
+ const partTop = part.getBoundingClientRect().top
+ state.maxPartTopMovement = Math.max(state.maxPartTopMovement, Math.abs(partTop - state.previousPartTop))
+ state.previousPartTop = partTop
+ }
+ const visibleRow = [...root.querySelectorAll("[data-timeline-row]")].some((element) => {
+ const rect = element.getBoundingClientRect()
+ return rect.bottom > viewport.top && rect.top < viewport.bottom
+ })
+ if (!visibleRow) state.blanks += 1
+ if (profileVisual) {
+ const subtrees = new Map()
+ const visibleSubtrees = new Map()
+ root.querySelectorAll(critical).forEach((element) => {
+ const key = describe(element)
+ const rect = element.getBoundingClientRect()
+ const style = getComputedStyle(element)
+ const rendered =
+ element.isConnected &&
+ rect.width > 0 &&
+ rect.height > 0 &&
+ style.display !== "none" &&
+ style.visibility !== "hidden" &&
+ Number(style.opacity) > 0
+ subtrees.set(key, { element, rendered })
+ if (rendered && rect.bottom > viewport.top && rect.top < viewport.bottom) {
+ const previous = state.visibleSubtrees.get(key)
+ if (previous && previous !== element && key.startsWith(`${textPartID}:`))
+ state.visibleSubtreeReplacements += 1
+ visibleSubtrees.set(key, element)
+ }
+ })
+ state.visibleSubtrees.forEach((element, key) => {
+ const current = subtrees.get(key)
+ if (key.startsWith(`${textPartID}:`) && !current?.rendered) {
+ const markdown = part.querySelector('[data-component="markdown"]')
+ state.visibleSubtreeDropouts.push(
+ `${key}:projection=${markdown?.dataset.markdownProjectionLength}/${markdown?.dataset.markdownProjectionBlocks}:result=${markdown?.dataset.markdownResultLength}/${markdown?.dataset.markdownResultBlocks}:applied=${markdown?.dataset.markdownAppliedBlocks}:dom=${markdown?.children.length}`,
+ )
+ }
+ if (element.matches('[data-component="file"]')) {
+ const hadLines = element.hasAttribute("data-profiler-had-lines")
+ const hasLines = element.shadowRoot?.querySelector("[data-line]") != null
+ if (hasLines) element.setAttribute("data-profiler-had-lines", "")
+ if (hadLines && !hasLines) state.visibleSubtreeDropouts.push(`${key}:shadow-lines`)
+ }
+ })
+ state.visibleSubtrees = visibleSubtrees
+ }
+ if (profileVisual && duration > 33.34) {
+ const livePart = currentPart()
+ const content = livePart?.textContent ?? ""
+ const complete = content.includes("benchmark-complete")
+ const index = complete
+ ? finalIndex
+ : Number(content.match(new RegExp(markerPattern, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1)
+ state.slowFrames.push({
+ duration,
+ index,
+ phase: complete
+ ? "complete"
+ : index >= 0 && index % fragmentCount === 0
+ ? "boundary"
+ : index >= 0
+ ? "stream"
+ : "unknown",
+ tokenSpans: livePart?.querySelectorAll(".shiki span").length ?? 0,
+ blocks: livePart?.querySelectorAll("[data-markdown-block]").length ?? 0,
+ codeBlocks: livePart?.querySelectorAll('[data-component="markdown-code"]').length ?? 0,
+ height: livePart?.getBoundingClientRect().height ?? 0,
+ distance: root.scrollHeight - root.clientHeight - root.scrollTop,
+ })
+ }
+ requestAnimationFrame(sample)
+ }, 0)
+ }
+ state.start = () => {
+ state.started = performance.now()
+ state.previous = state.started
+ state.running = true
+ requestAnimationFrame(sample)
+ }
+ },
+ { ...options, markerPattern: STREAM_MARKER_PATTERN, fragmentCount: STREAM_FRAGMENT_COUNT },
+ )
+}
+
+export function startTimelineStreamProbe(page: Page) {
+ return page.evaluate(() => {
+ const state = (window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark
+ if (!state) throw new Error("missing streaming benchmark state")
+ state.start()
+ })
+}
+
+type LayoutShiftEntry = PerformanceEntry & { value: number; hadRecentInput?: boolean }
+
+export function layoutShiftValue(
+ entry: Pick,
+ start: number,
+) {
+ if (entry.startTime < start || entry.hadRecentInput) return
+ return entry.value
+}
+
+export function removeVisibleRow(visible: Set, row: T) {
+ return visible.delete(row)
+}
+
+export function streamProgress(content: string) {
+ const index = Number(content.match(new RegExp(STREAM_MARKER_PATTERN, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1)
+ return {
+ index,
+ phase: content.includes("benchmark-complete")
+ ? ("complete" as const)
+ : index >= 0 && index % STREAM_FRAGMENT_COUNT === 0
+ ? ("boundary" as const)
+ : index >= 0
+ ? ("stream" as const)
+ : ("unknown" as const),
+ }
+}
+
+export async function collectTimelineStreamMetrics(
+ page: Page,
+ options: { textPartID: string; finalIndex: number; navigations: string[] },
+) {
+ return page.evaluate(({ textPartID, finalIndex, navigations }) => {
+ const state = (window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark
+ if (!state) throw new Error(`missing streaming benchmark state after navigation: ${JSON.stringify(navigations)}`)
+ state.ended = performance.now()
+ state.cleanup()
+ state.running = false
+ const part = document.querySelector(`[data-timeline-part-id="${textPartID}"]`)
+ const row = part?.closest("[data-timeline-row]")
+ const markdown = part?.querySelector('[data-component="markdown"]')
+ const sorted = state.frames.slice().sort((a, b) => a - b)
+ const duration = state.frames.reduce((sum, value) => sum + value, 0)
+ const longestSlowStreak = state.frames.reduce(
+ (result, value) => {
+ const current = value > 33.34 ? result.current + 1 : 0
+ return { current, longest: Math.max(result.longest, current) }
+ },
+ { current: 0, longest: 0 },
+ ).longest
+ const busyStart = state.applied.at(0)?.at
+ const completion = state.applied.find((value) => value.index === finalIndex)
+ const busyEnd = completion?.at
+ const busyFrames =
+ busyStart === undefined || busyEnd === undefined
+ ? []
+ : state.frames.filter((_, index) => state.frameAt[index]! >= busyStart && state.frameAt[index]! <= busyEnd)
+ const busySorted = busyFrames.slice().sort((a, b) => a - b)
+ const busyDuration = busyFrames.reduce((sum, value) => sum + value, 0)
+ const completionObservedMs = (completion?.at ?? NaN) - state.started
+ const visual = state.profileVisual
+ ? {
+ layoutShiftValueSum: state.layoutShifts.reduce((sum, value) => sum + value, 0),
+ maxLayoutShiftValue: Math.max(0, ...state.layoutShifts),
+ visibleMounts: state.visibleMounts,
+ visibleUnmounts: state.visibleUnmounts,
+ visibleSubtreeMounts: state.visibleSubtreeMounts,
+ visibleSubtreeUnmounts: [...new Set(state.visibleSubtreeUnmounts)],
+ visibleSubtreeReplacements: state.visibleSubtreeReplacements,
+ visibleSubtreeDropouts: [...new Set(state.visibleSubtreeDropouts)],
+ maxOverlapPx: state.maxOverlap,
+ maxGapPx: state.maxGap,
+ maxPartTopMovementPx: state.maxPartTopMovement,
+ slowestRafGaps: state.slowFrames
+ .sort((a, b) => b.duration - a.duration)
+ .slice(0, 20)
+ .map((frame) => ({
+ durationMs: frame.duration,
+ index: frame.index,
+ phase: frame.phase,
+ tokenSpans: frame.tokenSpans,
+ blocks: frame.blocks,
+ codeBlocks: frame.codeBlocks,
+ heightPx: frame.height,
+ distancePx: frame.distance,
+ })),
+ slowRafGapPhases: Object.fromEntries(
+ ["stream", "boundary", "complete", "unknown"].map((phase) => {
+ const frames = state.slowFrames.filter((frame) => frame.phase === phase)
+ return [
+ phase,
+ {
+ count: frames.length,
+ totalMs: frames.reduce((sum, frame) => sum + frame.duration, 0),
+ maxMs: Math.max(0, ...frames.map((frame) => frame.duration)),
+ },
+ ]
+ }),
+ ),
+ scroll: state.scroll,
+ }
+ : null
+ const geometry = state.minimal
+ ? null
+ : {
+ maxDistancePx: Math.max(0, ...state.geometry.map((sample) => sample.distance)),
+ finalDistancePx: state.geometry.at(-1)?.distance ?? 0,
+ final: state.geometry.at(-1),
+ distanceTransitionsPx: state.geometry
+ .map((sample) => Math.round(sample.distance))
+ .filter((value, index, values) => index === 0 || value !== values[index - 1]),
+ bottomDriftTransitions: state.geometry.slice(1).filter((value, index) => {
+ const previous = state.geometry[index]?.distance ?? 0
+ return previous <= 1 && value.distance > 1
+ }).length,
+ blankSamples: state.blanks,
+ }
+ return {
+ capabilities: { visual: state.profileVisual, geometry: !state.minimal },
+ completionObservedMs,
+ deltasPerSecond: Number.isFinite(completionObservedMs) ? finalIndex / (completionObservedMs / 1_000) : null,
+ rafGapSamples: state.frames.length,
+ rafCallbackRate: duration ? (state.frames.length * 1000) / duration : 0,
+ observedProgressWindowRafCallbackRate: busyDuration ? (busyFrames.length * 1000) / busyDuration : null,
+ observedProgressWindowRafGapP95Ms: busySorted[Math.floor(busySorted.length * 0.95)] ?? null,
+ observedProgressWindowRafGaps: busyFrames.length,
+ maxObservedProgressIndex: Math.max(-1, ...state.applied.map((value) => value.index)),
+ observedProgressTransitions: state.applied.length,
+ rafGapP50Ms: sorted[Math.floor(sorted.length * 0.5)] ?? 0,
+ rafGapP95Ms: sorted[Math.floor(sorted.length * 0.95)] ?? 0,
+ rafGapP99Ms: sorted[Math.floor(sorted.length * 0.99)] ?? 0,
+ maxRafGapMs: sorted.at(-1) ?? 0,
+ rafGapsOver33Ms: state.frames.filter((value) => value > 33.34).length,
+ rafGapsOver50Ms: state.frames.filter((value) => value > 50).length,
+ missedFrameBudgetEquivalents: state.frames.reduce(
+ (sum, value) => sum + Math.max(0, Math.round(value / 16.67) - 1),
+ 0,
+ ),
+ longestRafGapOver33MsStreak: longestSlowStreak,
+ longTaskCount: state.longTasks.length,
+ longTaskTimeMs: state.longTasks.reduce((sum, value) => sum + value, 0),
+ visual,
+ geometry,
+ rowReplaced: row !== state.row,
+ markdownReplaced: markdown !== state.markdown,
+ domTextCharacters: part?.textContent?.length ?? 0,
+ }
+ }, options)
+}
diff --git a/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts b/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts
new file mode 100644
index 00000000000..e5c353e4cd0
--- /dev/null
+++ b/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts
@@ -0,0 +1,335 @@
+const words = [
+ "alpha",
+ "bravo",
+ "charlie",
+ "delta",
+ "echo",
+ "foxtrot",
+ "golf",
+ "hotel",
+ "india",
+ "juliet",
+ "kilo",
+ "lima",
+ "metro",
+ "nova",
+ "orbit",
+ "pixel",
+ "quartz",
+ "river",
+ "signal",
+ "vector",
+]
+
+const sourceID = "ses_smoke_source"
+const targetID = "ses_smoke_target"
+const directory = "C:/OpenCode/SmokeProject"
+const projectID = "proj_smoke_timeline"
+const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
+
+type MessageInfo = Record & { id: string; role: "user" | "assistant" }
+type MessagePart = Record & { id: string; type: string; text?: string; tool?: string }
+type Message = { info: MessageInfo; parts: MessagePart[] }
+
+function lorem(seed: number, length: number) {
+ let out = ""
+ let i = seed
+ while (out.length < length) {
+ const word = words[i % words.length]
+ out += (out ? " " : "") + word
+ if (i % 17 === 0) out += ".\n\n"
+ i += 7
+ }
+ return out.slice(0, length)
+}
+
+function id(prefix: string, value: number) {
+ return `${prefix}_smoke_${String(value).padStart(4, "0")}`
+}
+
+function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message {
+ const messageID = id("msg_user", index)
+ return {
+ info: {
+ id: messageID,
+ sessionID,
+ role: "user",
+ time: { created: 1700000000000 + index * 10_000 },
+ summary: { diffs },
+ agent: "build",
+ model,
+ },
+ parts: [
+ {
+ id: id("prt_user_text", index),
+ sessionID,
+ messageID,
+ type: "text",
+ text: lorem(index, textLength),
+ },
+ ],
+ }
+}
+
+function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message {
+ const messageID = id("msg_assistant", index)
+ return {
+ info: {
+ id: messageID,
+ sessionID,
+ role: "assistant",
+ time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
+ parentID,
+ modelID: model.modelID,
+ providerID: model.providerID,
+ mode: "build",
+ agent: "build",
+ path: { cwd: directory, root: directory },
+ cost: 0.01,
+ tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
+ variant: "max",
+ finish: "stop",
+ },
+ parts: parts.map((part) => ({
+ ...part,
+ sessionID,
+ messageID,
+ })),
+ }
+}
+
+function textPart(index: number, partIndex: number, length: number): MessagePart {
+ const prose = lorem(index * 13 + partIndex, length)
+ const text =
+ index % 12 === 0
+ ? `${prose}\n\n\`\`\`ts\n${code(index, 80)}\n\`\`\``
+ : index % 5 === 0
+ ? `${prose}\n\n\`\`\`ts\nexport const value = "${lorem(index, 220)}"\n\`\`\``
+ : index % 7 === 0
+ ? `${prose}\n\nThe wrapped inline value is \`${lorem(index, 180)}\`.`
+ : prose
+ return { id: id(`prt_text_${partIndex}`, index), type: "text", text }
+}
+
+function reasoningPart(index: number, partIndex: number, length: number): MessagePart {
+ return {
+ id: id(`prt_reasoning_${partIndex}`, index),
+ type: "reasoning",
+ text: lorem(index * 19 + partIndex, length),
+ time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 500 },
+ }
+}
+
+function toolPart(
+ index: number,
+ partIndex: number,
+ tool: string,
+ input: Record,
+ outputLength = 160,
+): MessagePart {
+ const metadata =
+ tool === "apply_patch"
+ ? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
+ : tool === "edit" || tool === "write"
+ ? {
+ filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index),
+ diff: patch(index, outputLength),
+ preview: patch(index + 1, 420),
+ }
+ : tool === "question"
+ ? { answers: [["Proceed"], ["Keep sample output"]] }
+ : {}
+ return {
+ id: id(`prt_tool_${tool}_${partIndex}`, index),
+ type: "tool",
+ callID: id("call", index * 10 + partIndex),
+ tool,
+ state: {
+ status: "completed",
+ input,
+ output: lorem(index * 23 + partIndex, outputLength),
+ title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed",
+ metadata,
+ time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
+ },
+ }
+}
+
+function patchFile(seed: number, type: "add" | "update" | "delete") {
+ return {
+ filePath: `src/generated/patch-${seed}.ts`,
+ relativePath: `src/generated/patch-${seed}.ts`,
+ type,
+ additions: (seed % 7) + 1,
+ deletions: type === "add" ? 0 : seed % 4,
+ patch: patch(seed, 520),
+ before: type === "add" ? undefined : code(seed, 18),
+ after: type === "delete" ? undefined : code(seed + 1, 24),
+ }
+}
+
+function fileDiff(file: string, seed: number) {
+ const lines = seed % 12 === 0 ? 300 : seed % 8 === 0 ? 2 : 38
+ const before = code(seed, lines, seed % 10 === 0 ? 280 : 32)
+ const after =
+ lines === 2
+ ? before.replace("value1", "updatedValue1")
+ : lines === 300
+ ? code(seed + 1, lines, seed % 10 === 0 ? 280 : 32)
+ : before.replace("value4", "updatedValue4").replace("value20", "updatedValue20")
+ return {
+ file,
+ additions: lines === 300 ? 300 : lines === 2 ? 1 : 2,
+ deletions: lines === 300 ? 300 : lines === 2 ? 1 : 2,
+ before,
+ after,
+ }
+}
+
+function patch(seed: number, length: number) {
+ return `diff --git a/src/generated/file-${seed}.ts b/src/generated/file-${seed}.ts\n+${lorem(seed, length).replace(/\n/g, "\n+")}`
+}
+
+function code(seed: number, lines: number, width = 32) {
+ return Array.from(
+ { length: lines },
+ (_, index) => `export const value${index} = "${lorem(seed + index, width)}"`,
+ ).join("\n")
+}
+
+function turn(index: number): Message[] {
+ const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : []
+ const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff)
+ const parts = [
+ ...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []),
+ ...(index % 3 === 0
+ ? [
+ toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
+ toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140),
+ toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180),
+ toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120),
+ ]
+ : []),
+ textPart(index, 2, 160 + (index % 6) * 90),
+ ...(index % 4 === 0 ? [toolPart(index, 3, "edit", { filePath: `src/generated/file-${index}.ts` }, 700)] : []),
+ ...(index % 6 === 0
+ ? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
+ : []),
+ ...(index % 8 === 0
+ ? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
+ : []),
+ ...(index % 7 === 0
+ ? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
+ : []),
+ ...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
+ ...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
+ ...(index % 13 === 0
+ ? [
+ toolPart(
+ index,
+ 11,
+ "question",
+ { questions: [{ question: "Use generated fixture?" }, { question: "Keep same row shape?" }] },
+ 120,
+ ),
+ ]
+ : []),
+ ...(index % 17 === 0
+ ? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
+ : []),
+ ]
+ return [user, assistantMessage(targetID, index, user.info.id, parts)]
+}
+
+const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
+const sourceMessages = Array.from({ length: 12 }, (_, index) => [
+ userMessage(sourceID, index + 1000, 120),
+ assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
+]).flat()
+
+function renderable(part: MessagePart) {
+ if (part.type === "tool" && part.tool === "todowrite") return false
+ if (part.type === "text") return !!part.text.trim()
+ if (part.type === "reasoning") return !!part.text.trim()
+ return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
+}
+
+function orderedParts(message: Message) {
+ return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
+}
+
+export const fixture = {
+ directory,
+ project: {
+ id: projectID,
+ worktree: directory,
+ vcs: "git",
+ name: "smoke-project",
+ time: { created: 1700000000000, updated: 1700000000000 },
+ sandboxes: [],
+ },
+ provider: {
+ all: [
+ {
+ id: "opencode",
+ name: "OpenCode",
+ models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
+ },
+ ],
+ connected: ["opencode"],
+ default: { providerID: "opencode", modelID: "claude-opus-4-6" },
+ },
+ sessions: [
+ {
+ id: sourceID,
+ slug: "source",
+ projectID,
+ directory,
+ title: "Uncommitted changes inquiry",
+ version: "dev",
+ time: { created: 1700000000000, updated: 1700000000000 },
+ },
+ {
+ id: targetID,
+ slug: "target",
+ projectID,
+ directory,
+ title: "Example Game: sample jump movement & sample physics analysis",
+ version: "dev",
+ time: { created: 1700000001000, updated: 1700000001000 },
+ },
+ ],
+ sourceID,
+ targetID,
+ messages: { [sourceID]: sourceMessages, [targetID]: targetMessages },
+ expected: {
+ sourceTitle: "Uncommitted changes inquiry",
+ targetTitle: "Example Game: sample jump movement & sample physics analysis",
+ sourceMessageIDs: sourceMessages
+ .filter((message) => message.info.role === "user")
+ .map((message) => message.info.id),
+ targetMessageIDs: targetMessages
+ .filter((message) => message.info.role === "user")
+ .map((message) => message.info.id),
+ targetPartIDs: targetMessages.flatMap((message) =>
+ orderedParts(message)
+ .filter(renderable)
+ .map((part) => part.id),
+ ),
+ },
+}
+
+export function pageMessages(sessionID: string, limit: number, before?: string) {
+ const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
+ const end = before
+ ? Math.max(
+ 0,
+ messages.findIndex((message) => message.info.id === before),
+ )
+ : messages.length
+ const start = Math.max(0, end - limit)
+ return {
+ items: messages.slice(start, end),
+ cursor: start > 0 ? messages[start]!.info.id : undefined,
+ }
+}
diff --git a/packages/app/e2e/performance/timeline/timeline-test-helpers.ts b/packages/app/e2e/performance/timeline/timeline-test-helpers.ts
new file mode 100644
index 00000000000..dc7e1730718
--- /dev/null
+++ b/packages/app/e2e/performance/timeline/timeline-test-helpers.ts
@@ -0,0 +1,67 @@
+import type { Page } from "@playwright/test"
+import { base64Encode } from "@opencode-ai/core/util/encode"
+import { mockOpenCodeServer } from "../../utils/mock-server"
+import { fixture } from "./session-timeline-stress.fixture"
+
+export async function installTimelineSettings(page: Page) {
+ await page.addInitScript(() => {
+ localStorage.setItem(
+ "settings.v3",
+ JSON.stringify({
+ general: {
+ editToolPartsExpanded: true,
+ shellToolPartsExpanded: true,
+ showReasoningSummaries: true,
+ showSessionProgressBar: true,
+ },
+ }),
+ )
+ })
+}
+
+export function mockStressTimeline(page: Page) {
+ return mockOpenCodeServer(page, {
+ sessions: fixture.sessions,
+ provider: fixture.provider,
+ directory: fixture.directory,
+ project: fixture.project,
+ pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
+ })
+}
+
+export async function installStressSessionTabs(page: Page) {
+ const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
+ await page.addInitScript(
+ ({ directory, sourceID, targetID, dirBase64, server }) => {
+ localStorage.setItem(
+ "opencode.global.dat:server",
+ JSON.stringify({
+ projects: { local: [{ worktree: directory, expanded: true }] },
+ lastProject: { local: directory },
+ }),
+ )
+ localStorage.setItem(
+ "opencode.global.dat:tabs",
+ JSON.stringify(
+ [sourceID, targetID].map((sessionId) => ({
+ type: "session",
+ server,
+ dirBase64,
+ sessionId,
+ })),
+ ),
+ )
+ },
+ {
+ directory: fixture.directory,
+ sourceID: fixture.sourceID,
+ targetID: fixture.targetID,
+ dirBase64: base64Encode(fixture.directory),
+ server,
+ },
+ )
+}
+
+export function stressSessionHref(sessionID: string) {
+ return `/${base64Encode(fixture.directory)}/session/${sessionID}`
+}
diff --git a/packages/app/e2e/performance/unit/chrome-trace-write.test.ts b/packages/app/e2e/performance/unit/chrome-trace-write.test.ts
new file mode 100644
index 00000000000..456020ff30d
--- /dev/null
+++ b/packages/app/e2e/performance/unit/chrome-trace-write.test.ts
@@ -0,0 +1,15 @@
+import { expect, test } from "bun:test"
+import { mkdtemp, rm } from "node:fs/promises"
+import path from "node:path"
+import os from "node:os"
+import { prepareChromeTrace } from "../chrome-trace"
+
+test("creates the configured trace directory", async () => {
+ const root = await mkdtemp(path.join(os.tmpdir(), "opencode-trace-"))
+ try {
+ const file = await prepareChromeTrace(path.join(root, "nested", "traces"), "session/tab", false, "test")
+ expect(file).toEndWith("-session-tab-458ed9e3-test.json")
+ } finally {
+ await rm(root, { recursive: true, force: true })
+ }
+})
diff --git a/packages/app/e2e/performance/unit/session-tab-repaint-probe.test.ts b/packages/app/e2e/performance/unit/session-tab-repaint-probe.test.ts
new file mode 100644
index 00000000000..5d20c03a00f
--- /dev/null
+++ b/packages/app/e2e/performance/unit/session-tab-repaint-probe.test.ts
@@ -0,0 +1,42 @@
+import { expect, test } from "bun:test"
+import { compressCachedRepaintTrace, layoutShiftSample } from "../timeline/session-tab-repaint-probe"
+
+test("compresses repeated repaint states without losing frame samples", () => {
+ const state = {
+ root: 1,
+ scrollTop: 10,
+ scrollHeight: 20,
+ bottomErrorPx: 0,
+ last: true,
+ rows: [{ key: "row", node: 2, top: 0, bottom: 10 }],
+ mounted: 1,
+ center: "content",
+ }
+ const trace = {
+ timeOriginEpochMs: 1_000,
+ startedAtPerformanceMs: 100,
+ samples: [
+ { observedAtMs: 16, ...state, destination: ["target"], source: [] },
+ { observedAtMs: 32, ...state, destination: ["target"], source: [] },
+ { observedAtMs: 48, ...state, scrollTop: 11, destination: ["target"], source: [] },
+ ],
+ mutations: [{ observedAtMs: 20, changed: [{ type: "add", node: 2 }] }],
+ shifts: [{ occurredAtMs: 24, value: 0.1 }],
+ windowMs: 1_000,
+ running: false,
+ stop() {},
+ }
+ const compressed = compressCachedRepaintTrace(trace)
+ const samples = compressed.samples.flatMap((group) =>
+ group.observedAtMs.map((observedAtMs) => ({ observedAtMs, ...group.state })),
+ )
+
+ expect(samples).toEqual(trace.samples)
+ expect(compressed.mutations).toEqual(trace.mutations)
+ expect(compressed.shifts).toEqual(trace.shifts)
+})
+
+test("records layout shifts at occurrence time within the probe window", () => {
+ expect(layoutShiftSample({ startTime: 99, value: 0.1 }, 100)).toBeUndefined()
+ expect(layoutShiftSample({ startTime: 124, value: 0.2 }, 100)).toEqual({ occurredAtMs: 24, value: 0.2 })
+})
diff --git a/packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts b/packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts
new file mode 100644
index 00000000000..dd771b7d57c
--- /dev/null
+++ b/packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts
@@ -0,0 +1,54 @@
+import { expect, test } from "bun:test"
+import { classifySessionSwitch } from "../timeline/session-tab-switch-metrics"
+
+test("counts source and blank samples before the destination is observed", () => {
+ const result = classifySessionSwitch([
+ { observedAtMs: 16, destination: [], source: ["source"], hasVisibleRows: true, last: false },
+ { observedAtMs: 32, destination: [], source: [], hasVisibleRows: false, last: false },
+ { observedAtMs: 48, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
+ { observedAtMs: 64, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
+ { observedAtMs: 80, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
+ ])
+
+ expect(result.blankSamples).toBe(1)
+ expect(result.sourceSamples).toBe(1)
+ expect(result.unknownSamples).toBe(0)
+ expect(result.firstDestinationObservedMs).toBe(48)
+ expect(result.stableObservedMs).toBe(80)
+})
+
+test("does not classify mixed source and destination content as correct", () => {
+ const result = classifySessionSwitch([
+ {
+ observedAtMs: 16,
+ destination: ["destination"],
+ source: ["source"],
+ hasVisibleRows: true,
+ last: true,
+ bottomErrorPx: 0,
+ },
+ { observedAtMs: 32, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
+ { observedAtMs: 48, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
+ { observedAtMs: 64, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
+ ])
+
+ expect(result.firstCorrectObservedMs).toBe(32)
+ expect(result.stableObservedMs).toBe(64)
+})
+
+test("reports missing correctness without throwing", () => {
+ const result = classifySessionSwitch([
+ {
+ observedAtMs: 16,
+ destination: ["destination"],
+ source: ["source"],
+ hasVisibleRows: true,
+ last: true,
+ bottomErrorPx: 0,
+ },
+ ])
+
+ expect(result.firstDestinationObservedMs).toBe(16)
+ expect(result.firstCorrectObservedMs).toBeNull()
+ expect(result.stableObservedMs).toBeNull()
+})
diff --git a/packages/app/e2e/performance/unit/session-timeline-stream-probe.test.ts b/packages/app/e2e/performance/unit/session-timeline-stream-probe.test.ts
new file mode 100644
index 00000000000..f8fca1adb73
--- /dev/null
+++ b/packages/app/e2e/performance/unit/session-timeline-stream-probe.test.ts
@@ -0,0 +1,14 @@
+import { expect, test } from "bun:test"
+import { streamChunk } from "../timeline/session-timeline-benchmark.fixture"
+import { streamProgress } from "../timeline/session-timeline-stream-probe"
+
+test("classifies emitted stream markers using the fixture cycle", () => {
+ expect(streamProgress("before stream-17 after stream-18")).toEqual({ index: 18, phase: "boundary" })
+ expect(streamProgress("before stream-18 after stream-19")).toEqual({ index: 19, phase: "stream" })
+ expect(streamProgress("benchmark-complete stream-36")).toEqual({ index: 36, phase: "complete" })
+ expect(streamProgress("no marker")).toEqual({ index: -1, phase: "unknown" })
+})
+
+test("emits progress markers at fixture boundaries", () => {
+ expect(streamProgress(streamChunk(18, 160))).toEqual({ index: 18, phase: "boundary" })
+})
diff --git a/packages/app/e2e/performance/unit/session-timeline-visual-tracking.test.ts b/packages/app/e2e/performance/unit/session-timeline-visual-tracking.test.ts
new file mode 100644
index 00000000000..c0215c5cb6c
--- /dev/null
+++ b/packages/app/e2e/performance/unit/session-timeline-visual-tracking.test.ts
@@ -0,0 +1,16 @@
+import { expect, test } from "bun:test"
+import { layoutShiftValue, removeVisibleRow } from "../timeline/session-timeline-stream-probe"
+
+test("excludes layout shifts before the probe window and recent input", () => {
+ expect(layoutShiftValue({ startTime: 9, value: 0.1 }, 10)).toBeUndefined()
+ expect(layoutShiftValue({ startTime: 10, value: 0.2, hadRecentInput: true }, 10)).toBeUndefined()
+ expect(layoutShiftValue({ startTime: 11, value: 0.3 }, 10)).toBe(0.3)
+})
+
+test("classifies removed rows from their last painted visibility", () => {
+ const row = {}
+ const visible = new Set([row])
+
+ expect(removeVisibleRow(visible, row)).toBe(true)
+ expect(removeVisibleRow(visible, row)).toBe(false)
+})
diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts
index c4ef9f6cc84..cf0c9524314 100644
--- a/packages/app/e2e/utils/mock-server.ts
+++ b/packages/app/e2e/utils/mock-server.ts
@@ -44,7 +44,10 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
- if (url.port !== targetPort) return route.fallback()
+ const appPort = new URL(
+ process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`,
+ ).port
+ if (url.port !== targetPort && url.port !== appPort) return route.fallback()
const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
@@ -72,7 +75,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
return json(route, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined)
}
- return json(route, {})
+ if (url.port === targetPort && targetPort !== appPort) return json(route, {})
+ return route.fallback()
})
}
diff --git a/packages/app/package.json b/packages/app/package.json
index 0b46ec02873..b572e7308b7 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -24,7 +24,8 @@
"test:e2e": "playwright test",
"test:e2e:local": "playwright test",
"test:e2e:ui": "playwright test --ui",
- "test:e2e:report": "playwright show-report e2e/playwright-report"
+ "test:e2e:report": "playwright show-report e2e/playwright-report",
+ "test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts"
},
"license": "MIT",
"devDependencies": {
diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts
index d9648a88ba6..f68652363d3 100644
--- a/packages/app/playwright.config.ts
+++ b/packages/app/playwright.config.ts
@@ -9,6 +9,7 @@ const reuse = !process.env.CI
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
export default defineConfig({
testDir: "./e2e",
+ testIgnore: process.env.OPENCODE_PERFORMANCE === "1" ? "performance/**/*.test.ts" : "performance/**",
outputDir: "./e2e/test-results",
timeout: 60_000,
expect: {
From 3f1fffeb6d22fe2f116ddf03c9eed205d6ae70f8 Mon Sep 17 00:00:00 2001
From: Grant Martin <69131375+Grantmartin2002@users.noreply.github.com>
Date: Fri, 19 Jun 2026 17:22:27 -0500
Subject: [PATCH 008/112] fix(core): fix command docs in customize-opencode
skill (#32718)
---
packages/core/src/plugin/skill.ts | 2 +-
.../src/plugin/skill/customize-opencode.md | 34 +++++++++++++++++--
2 files changed, 32 insertions(+), 4 deletions(-)
diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts
index 7c89ac8e337..620fdc8b9ab 100644
--- a/packages/core/src/plugin/skill.ts
+++ b/packages/core/src/plugin/skill.ts
@@ -23,7 +23,7 @@ export const Plugin = PluginV2.define({
skill: new SkillV2.Info({
name: "customize-opencode",
description:
- "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.",
+ "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.",
location: AbsolutePath.make("/builtin/customize-opencode.md"),
content: CustomizeOpencodeContent,
}),
diff --git a/packages/core/src/plugin/skill/customize-opencode.md b/packages/core/src/plugin/skill/customize-opencode.md
index 1c1cbdf3c29..6932dbfd54c 100644
--- a/packages/core/src/plugin/skill/customize-opencode.md
+++ b/packages/core/src/plugin/skill/customize-opencode.md
@@ -43,6 +43,8 @@ already-loaded config until then.
| Global config | `~/.config/opencode/opencode.json` (NOT `~/.opencode/`) |
| Project agents | `.opencode/agent/.md` or `.opencode/agents/.md` |
| Global agents | `~/.config/opencode/agent(s)/.md` |
+| Project commands | `.opencode/command/.md` or `.opencode/commands/.md` |
+| Global commands | `~/.config/opencode/command(s)/.md` |
| Project skills | `.opencode/skill(s)//SKILL.md` |
| Global skills | `~/.config/opencode/skill(s)//SKILL.md` |
| External skills (auto-loaded) | `~/.claude/skills//SKILL.md`, `~/.agents/skills//SKILL.md` |
@@ -96,7 +98,7 @@ Every field is optional.
},
"command": {
- "deploy": { "description": "...", "prompt": "..." }
+ "deploy": { "description": "...", "template": "..." }
},
"provider": {
@@ -151,6 +153,7 @@ Shape notes worth being explicit about:
- `skills` is an object with `paths` and/or `urls`, not an array.
- `references` is an object keyed by alias. Each value is a local path, Git repository, or string shorthand.
- `agent` is an object keyed by agent name, not an array.
+- `command` is an object keyed by command name, not an array.
- `plugin` is an array of strings or `[name, options]` tuples, not an object.
- `mcp[name].command` is an array of strings, never a single string. `type` is required.
- `permission` is either a string action or an object keyed by tool name.
@@ -277,6 +280,31 @@ opencode ships with `build`, `plan`, `general`, `explore`. Hidden internal agent
`compaction`, `title`, `summary`. To override a built-in's fields, define the
same key in `agent: { : { ... } }`.
+## Commands
+
+opencode's command loader scans for `**/*.md` inside command directories. The
+file is named after the command, and lives directly inside the `command` folder:
+
+```
+.opencode/command/deploy.md
+```
+
+Frontmatter:
+
+```markdown
+---
+description: One sentence describing what the command does.
+agent: build
+model: anthropic/claude-sonnet-4-6
+---
+
+(command body in markdown: the prompt opencode runs, with $ARGUMENTS for the user's input)
+```
+
+- `template` is the command body — everything below the frontmatter — and is required: it is the prompt opencode runs when the command is invoked. Do not also put a `template:` key in the frontmatter.
+- `$ARGUMENTS` is replaced with everything the user typed after the command; `$1`, `$2`, … pull individual positional arguments.
+- Optional: `description`, `agent`, `model`, `variant`, `subtask`.
+
## Plugins
`plugin:` is an array. Each entry is one of:
@@ -415,8 +443,8 @@ When a user's config is broken and opencode won't start, these env vars help:
exact shape, or the field is not covered in this skill, fetch
`https://opencode.ai/config.json` and read the schema rather than guessing.
- Preserve `$schema` and any existing fields the user did not ask to change.
-- For agent, skill, and plugin definitions, prefer creating new files in the
- correct location over inlining everything in `opencode.json`.
+- For agent, command, skill, and plugin definitions, prefer creating new files
+ in the correct location over inlining everything in `opencode.json`.
- If the user's existing config is malformed, point them at the env-var escape
hatches above so they can edit from inside opencode without breaking their
session.
From f092bafe88673db9cf3112daa2de7c768ef34aef Mon Sep 17 00:00:00 2001
From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Date: Fri, 19 Jun 2026 19:10:09 -0400
Subject: [PATCH 009/112] tweak: remove steering wrapper that can bust cache
(#33039)
---
packages/opencode/src/session/prompt.ts | 18 ------------------
packages/opencode/test/session/prompt.test.ts | 4 +++-
2 files changed, 3 insertions(+), 19 deletions(-)
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index b3f85c813f2..b616df6e598 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -1304,24 +1304,6 @@ export const layer = Layer.effect(
if (step === 1)
yield* summary.summarize({ sessionID, messageID: lastUser.id }).pipe(Effect.ignore, Effect.forkIn(scope))
- if (step > 1 && lastFinished) {
- for (const m of msgs) {
- if (m.info.role !== "user" || m.info.id <= lastFinished.id) continue
- for (const p of m.parts) {
- if (p.type !== "text" || p.ignored || p.synthetic) continue
- if (!p.text.trim()) continue
- p.text = [
- "",
- "The user sent the following message:",
- p.text,
- "",
- "Please address this message and continue with your tasks.",
- "",
- ].join("\n")
- }
- }
- }
-
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const [skills, env, instructions, modelMsgs] = yield* Effect.all([
diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts
index 08828018a4a..5cd97f78e88 100644
--- a/packages/opencode/test/session/prompt.test.ts
+++ b/packages/opencode/test/session/prompt.test.ts
@@ -1346,7 +1346,9 @@ it.instance(
const inputs = yield* llm.inputs
expect(inputs).toHaveLength(2)
- expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("second")
+ const messages = inputs.at(-1)?.messages
+ if (!Array.isArray(messages)) throw new Error("expected LLM messages")
+ expect(messages.at(-1)).toEqual({ role: "user", content: "second" })
}),
3_000,
)
From e6cdc543f323bceb8a3e140d35d902c26e69169e Mon Sep 17 00:00:00 2001
From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Date: Fri, 19 Jun 2026 19:41:22 -0400
Subject: [PATCH 010/112] fix(tui): render console org load errors inline
(#33040)
---
.../tui/src/component/dialog-console-org.tsx | 44 ++++++++++++++++---
1 file changed, 38 insertions(+), 6 deletions(-)
diff --git a/packages/tui/src/component/dialog-console-org.tsx b/packages/tui/src/component/dialog-console-org.tsx
index ec3f8bea54d..1305a965cbf 100644
--- a/packages/tui/src/component/dialog-console-org.tsx
+++ b/packages/tui/src/component/dialog-console-org.tsx
@@ -1,9 +1,11 @@
-import { createResource, createMemo } from "solid-js"
+import { createResource, createMemo, createSignal } from "solid-js"
+import { TextAttributes } from "@opentui/core"
import { DialogSelect } from "../ui/dialog-select"
import { useSDK } from "../context/sdk"
import { useDialog } from "../ui/dialog"
import { useToast } from "../ui/toast"
import { useTheme } from "../context/theme"
+import { errorMessage } from "../util/error"
import type { ExperimentalConsoleListOrgsResponse } from "@opencode-ai/sdk/v2"
type OrgOption = ExperimentalConsoleListOrgsResponse["orgs"][number]
@@ -25,14 +27,26 @@ export function DialogConsoleOrg() {
const toast = useToast()
const { theme } = useTheme()
- const [orgs] = createResource(async () => {
- const result = await sdk.client.experimental.console.listOrgs({}, { throwOnError: true })
- return result.data?.orgs ?? []
- })
+ const [loadError, setLoadError] = createSignal()
+
+ const [orgs] = createResource(() =>
+ sdk.client.experimental.console
+ .listOrgs({}, { throwOnError: true })
+ .then((result) => result.data?.orgs ?? [])
+ // Catch so the rejected resource never reaches the memos below: reading
+ // orgs() in an errored state re-throws and tears down the dialog.
+ .catch((error) => {
+ setLoadError(error)
+ return undefined
+ }),
+ )
+
+ const showError = createMemo(() => Boolean(loadError()))
const current = createMemo(() => orgs()?.find((item) => item.active))
const options = createMemo(() => {
+ if (showError()) return []
const listed = orgs()
if (listed === undefined) {
return [
@@ -99,5 +113,23 @@ export function DialogConsoleOrg() {
}))
})
- return title="Switch org" options={options()} current={current()} />
+ return (
+
+ title="Switch org"
+ options={options()}
+ current={current()}
+ renderFilter={!showError()}
+ locked={showError()}
+ emptyView={
+ showError() ? (
+
+
+ Could not load orgs
+
+ {errorMessage(loadError())}
+
+ ) : undefined
+ }
+ />
+ )
}
From 95237a90a707a527e5fdeb68e8b1222d2cf76b9b Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Sat, 20 Jun 2026 06:14:06 -0500
Subject: [PATCH 011/112] fix(stats): align model peers ranking
---
packages/stats/core/src/domain/home.ts | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/packages/stats/core/src/domain/home.ts b/packages/stats/core/src/domain/home.ts
index 3023ae32365..d420e52c65e 100644
--- a/packages/stats/core/src/domain/home.ts
+++ b/packages/stats/core/src/domain/home.ts
@@ -248,15 +248,15 @@ function buildStatsModelData(
)
.filter((item) => item.totalTokens > 0)
.toSorted((a, b) => b.totalTokens - a.totalTokens || a.model.localeCompare(b.model))
- const peers = aggregateByModelName(rowsForProduct(normalized, SITE_PRODUCT, window.start, window.end))
+ const windowPeers = aggregateByModelName(rowsForProduct(normalized, SITE_PRODUCT, window.start, window.end))
.filter((item) => item.totalTokens > 0)
.toSorted((a, b) => b.totalTokens - a.totalTokens || a.model.localeCompare(b.model))
const rankIndex = rankPeers.findIndex((item) => item.model === model)
const rank = rankIndex >= 0 ? rankIndex + 1 : null
const previousRankIndex = previousRankPeers.findIndex((item) => item.model === model)
- const peerRankIndex = peers.findIndex((item) => item.model === model)
- const peerRank = peerRankIndex >= 0 ? peerRankIndex + 1 : 1
- const totalTokens = peers.reduce((sum, item) => sum + item.totalTokens, 0)
+ const peerRank = rankIndex >= 0 ? rankIndex + 1 : 1
+ const totalTokens = windowPeers.reduce((sum, item) => sum + item.totalTokens, 0)
+ const peerTokens = rankPeers.reduce((sum, item) => sum + item.totalTokens, 0)
return {
updatedAt: Number.isFinite(latestUpdate) ? new Date(latestUpdate).toISOString() : null,
@@ -266,7 +266,7 @@ function buildStatsModelData(
author: formatProvider(current.provider),
rank,
previousRank: previousRankIndex >= 0 ? previousRankIndex + 1 : null,
- totalModels: peers.length,
+ totalModels: windowPeers.length,
tokenShare: totalTokens > 0 ? round((current.totalTokens / totalTokens) * 100, 2) : 0,
tokenChange: percentChange(current.totalTokens, previous.totalTokens),
totals: {
@@ -285,7 +285,7 @@ function buildStatsModelData(
usage: buildModelUsage(currentRows, window, "2M"),
tokenMix: buildModelTokenMix(current),
country: createRangeRecord((range) => buildCountryStats(geo, getWindow(range, earliest, latest))),
- peers: buildModelPeers(peers, peerRank, totalTokens),
+ peers: buildModelPeers(rankPeers, peerRank, peerTokens),
}
}
From 009f3799cd6d28cad5a3e1b3902a80f60f93122e Mon Sep 17 00:00:00 2001
From: Dax
Date: Sat, 20 Jun 2026 15:35:20 +0200
Subject: [PATCH 012/112] refactor(tui): simplify inline tool spacing (#33097)
---
packages/tui/src/routes/session/index.tsx | 47 +++---------
.../inline-tool-wrap-snapshot.test.tsx.snap | 15 ++--
.../tui/inline-tool-wrap-snapshot.test.tsx | 72 +++++++++++--------
3 files changed, 62 insertions(+), 72 deletions(-)
diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx
index 4d983ee99b7..f36ddd9daba 100644
--- a/packages/tui/src/routes/session/index.tsx
+++ b/packages/tui/src/routes/session/index.tsx
@@ -91,6 +91,8 @@ const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_don
const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs
const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"])
+export const alwaysSeparate = new WeakSet()
+
type RetryAction = Extract["action"]
function goUpsellKeys(action: RetryAction) {
@@ -160,7 +162,6 @@ const context = createContext<{
showTimestamps: () => boolean
showDetails: () => boolean
showGenericToolOutput: () => boolean
- userMessageIDs: () => ReadonlySet
diffWrapMode: () => "word" | "none"
providers: () => ReadonlyMap
sync: ReturnType
@@ -218,14 +219,6 @@ export function Session() {
)
: [],
)
- const userMessageIDs = createMemo(
- () =>
- new Set(
- messages()
- .filter((message) => message.role === "user")
- .map((message) => message.id),
- ),
- )
const permissions = createMemo(() => {
if (session()?.parentID) return []
return children().flatMap((x) => sync.data.permission[x.id] ?? [])
@@ -1158,7 +1151,6 @@ export function Session() {
showTimestamps,
showDetails,
showGenericToolOutput,
- userMessageIDs,
diffWrapMode,
providers,
sync,
@@ -1395,6 +1387,7 @@ function UserMessage(props: {
alwaysSeparate.add(el)}
border={["left"]}
borderColor={color()}
customBorderChars={SplitBorder.customBorderChars}
@@ -1532,7 +1525,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
alwaysSeparate.add(el)}
border={["left"]}
paddingTop={1}
paddingBottom={1}
@@ -1547,7 +1540,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
-
+ alwaysSeparate.add(el)} paddingLeft={3}>
alwaysSeparate.add(el)}
paddingLeft={3}
marginTop={1}
flexDirection="column"
@@ -1695,7 +1688,7 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
const { theme, syntax } = useTheme()
return (
-
+ alwaysSeparate.add(el)} paddingLeft={3} marginTop={1} flexShrink={0}>
void
@@ -1886,7 +1878,6 @@ function InlineTool(props: {
return (
id !== undefined && ctx.userMessageIDs().has(id)}
onMouseOver={() => clickable() && setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => {
@@ -1918,7 +1907,6 @@ function InlineTool(props: {
}
export function InlineToolRow(props: {
- id?: string
icon: string
iconColor?: RGBA
color?: RGBA
@@ -1931,32 +1919,20 @@ export function InlineToolRow(props: {
pending: string
failure?: string
spinner?: boolean
- subagent?: boolean
children: JSX.Element
- separateAfter?: (id: string | undefined) => boolean
onMouseOver?: () => void
onMouseOut?: () => void
onMouseUp?: () => void
}) {
return (
{
setPreLayoutSiblingMargin(el, (previous) => {
- const previousInline = previous?.id.startsWith("tool-inline-") ?? false
- const previousSubagent = previous?.id.startsWith("tool-inline-subagent-") ?? false
- return previous?.id.startsWith("text-") ||
- previous?.id.startsWith("tool-block-") ||
- previous?.id.startsWith("assistant-error-") ||
- previous?.id.startsWith("assistant-summary-") ||
- (previousInline && previousSubagent !== Boolean(props.subagent)) ||
- props.separateAfter?.(previous?.id)
- ? 1
- : 0
+ return previous instanceof BoxRenderable && (previous.height > 1 || alwaysSeparate.has(previous)) ? 1 : 0
})
}}
>
@@ -2018,7 +1994,7 @@ function BlockTool(props: {
const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined))
return (
alwaysSeparate.add(el)}
border={["left"]}
paddingTop={1}
paddingBottom={1}
@@ -2184,8 +2160,8 @@ function Read(props: ToolProps) {
Read {pathFormatter.format(stringValue(props.input.filePath))} {input(props.input, ["filePath"])}
- {(filepath, index) => (
-
+ {(filepath) => (
+
↳ Loaded {pathFormatter.format(filepath)}
@@ -2305,7 +2281,6 @@ function Task(props: ToolProps) {
return (
+ alwaysSeparate.add(el)}
+ marginTop={1}
+ paddingTop={1}
+ paddingBottom={1}
+ paddingLeft={2}
+ gap={1}
+ >
# List files
$ ls
@@ -65,7 +73,7 @@ function ShellOutput() {
function UserMessage() {
return (
-
+ alwaysSeparate.add(el)}>
Check whether the next tool remains separated.
@@ -88,7 +96,6 @@ function Fixture(props: { errorExpanded?: boolean; before?: "shell" | "user" })
failed={Boolean(item.error)}
error={item.error}
errorExpanded={props.errorExpanded}
- separateAfter={(id) => id === "message-user"}
>
{item.label}
@@ -99,61 +106,67 @@ function Fixture(props: { errorExpanded?: boolean; before?: "shell" | "user" })
)
}
-function SubagentGroupFixture() {
+function TaskRowsFixture() {
return (
-
+
Grep "Task" (2 matches)
-
+
Explore Task — Inspect active task spacing
-
+
{"General Task — Confirm completed task spacing\n↳ 1 toolcall · 501ms"}
-
+
Read src/cli/cmd/tui/routes/session/index.tsx
)
}
-function LoadedReadBeforeSubagentFixture() {
+function LoadedReadBeforeTaskFixture() {
return (
-
+
Read src/cli/cmd/tui/routes/session/index.tsx
-
+
↳ Loaded src/cli/cmd/tui/routes/session/tools.tsx
-
+
{"Explore Task — Inspect active task spacing\n↳ 1 toolcall · 501ms"}
)
}
-function AssistantSummaryBeforeSubagentFixture() {
+function AssistantSummaryBeforeInlineFixture() {
return (
-
+ alwaysSeparate.add(el)} paddingLeft={3}>
▣ Build · Little Frank · 53.1s
-
+
{"Build Task — Review changes\n↳ 48 toolcalls · 1m 40s"}
)
}
-function AssistantErrorBeforeSubagentFixture() {
+function AssistantErrorBeforeInlineFixture() {
return (
-
+ alwaysSeparate.add(el)}
+ border={["left"]}
+ paddingTop={1}
+ paddingBottom={1}
+ paddingLeft={2}
+ >
Managed inference requires an active Member plan
-
+
{"Build Task — Review changes\n↳ 48 toolcalls · 1m 40s"}
@@ -170,7 +183,7 @@ function StickyScrollFixture(props: { separated: boolean; scroll: (scroll: Scrol
Second row
-
+ alwaysSeparate.add(el)}>
Assistant text
@@ -200,6 +213,7 @@ function FailedCompleteToolFixture() {
async function renderFrame(component: () => JSX.Element, options: { width: number; height: number }) {
testSetup = await testRender(component, options)
await testSetup.renderOnce()
+ await testSetup.renderOnce()
return testSetup
.captureCharFrame()
@@ -294,22 +308,20 @@ describe("TUI inline tool wrapping", () => {
expect(await renderFrame(() => , { width: 72, height: 14 })).toMatchSnapshot()
})
- test("separates a contiguous subagent group from inline tools", async () => {
- expect(await renderFrame(() => , { width: 72, height: 10 })).toMatchSnapshot()
+ test("separates after a multi-line task row", async () => {
+ expect(await renderFrame(() => , { width: 72, height: 10 })).toMatchSnapshot()
})
- test("separates a subagent group after an expanded read", async () => {
- expect(await renderFrame(() => , { width: 72, height: 8 })).toMatchSnapshot()
+ test("does not treat task rows differently from other inline rows", async () => {
+ expect(await renderFrame(() => , { width: 72, height: 8 })).toMatchSnapshot()
})
- test("separates a subagent from the previous assistant summary", async () => {
- expect(
- await renderFrame(() => , { width: 72, height: 5 }),
- ).toMatchSnapshot()
+ test("separates an inline row from the previous assistant summary", async () => {
+ expect(await renderFrame(() => , { width: 72, height: 5 })).toMatchSnapshot()
})
- test("separates a subagent from the previous assistant error", async () => {
- expect(await renderFrame(() => , { width: 72, height: 7 })).toMatchSnapshot()
+ test("separates an inline row from the previous assistant error", async () => {
+ expect(await renderFrame(() => , { width: 72, height: 7 })).toMatchSnapshot()
})
test("updates sticky-bottom geometry when a text separator mounts and unmounts", async () => {
From babe5070e29e43cc183a8c7acf0af40def343967 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8D=AB=E6=96=AF=E6=9D=8E?=
<41602358+Robin1987China@users.noreply.github.com>
Date: Sun, 21 Jun 2026 03:19:27 +0800
Subject: [PATCH 013/112] fix(opencode): use toLowerCase for Devstral model
detection (#33109)
Co-authored-by: Robin1987China
---
packages/opencode/src/provider/transform.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts
index 92ff8fece83..f78cc53e756 100644
--- a/packages/opencode/src/provider/transform.ts
+++ b/packages/opencode/src/provider/transform.ts
@@ -218,7 +218,7 @@ function normalizeMessages(
if (
model.providerID === "mistral" ||
model.api.id.toLowerCase().includes("mistral") ||
- model.api.id.toLocaleLowerCase().includes("devstral")
+ model.api.id.toLowerCase().includes("devstral")
) {
const scrub = (id: string) => {
return id
From 2d993cd0d57ad7e265dea70b314941b8dbd95c79 Mon Sep 17 00:00:00 2001
From: Lucas Kim <41357160+kimnamu@users.noreply.github.com>
Date: Sun, 21 Jun 2026 04:35:05 +0900
Subject: [PATCH 014/112] fix(experimental llm pkg): forward topK to Converse
via additionalModelRequestFields (#33030)
---
.../llm/src/protocols/bedrock-converse.ts | 3 +++
.../test/provider/bedrock-converse.test.ts | 20 +++++++++++++++++++
2 files changed, 23 insertions(+)
diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts
index 42dcaef5400..80412ca9ddb 100644
--- a/packages/llm/src/protocols/bedrock-converse.ts
+++ b/packages/llm/src/protocols/bedrock-converse.ts
@@ -412,6 +412,9 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
stopSequences: generation?.stop,
},
toolConfig,
+ // Converse's base inferenceConfig has no topK; Anthropic/Nova accept it
+ // as a model-specific field, so it goes through additionalModelRequestFields.
+ additionalModelRequestFields: generation?.topK === undefined ? undefined : { top_k: generation.topK },
}
})
diff --git a/packages/llm/test/provider/bedrock-converse.test.ts b/packages/llm/test/provider/bedrock-converse.test.ts
index 23483dda251..46657331a34 100644
--- a/packages/llm/test/provider/bedrock-converse.test.ts
+++ b/packages/llm/test/provider/bedrock-converse.test.ts
@@ -83,6 +83,26 @@ describe("Bedrock Converse route", () => {
}),
)
+ it.effect("passes topK through additionalModelRequestFields as top_k", () =>
+ Effect.gen(function* () {
+ const prepared = yield* LLMClient.prepare(
+ LLM.updateRequest(baseRequest, { generation: { maxTokens: 64, temperature: 0, topK: 40 } }),
+ )
+
+ // Converse's inferenceConfig has no topK; Anthropic/Nova read it from
+ // additionalModelRequestFields as top_k.
+ expect(prepared.body.inferenceConfig).toEqual({ maxTokens: 64, temperature: 0 })
+ expect(prepared.body.additionalModelRequestFields).toEqual({ top_k: 40 })
+ }),
+ )
+
+ it.effect("omits additionalModelRequestFields when topK is unset", () =>
+ Effect.gen(function* () {
+ const prepared = yield* LLMClient.prepare(baseRequest)
+ expect(prepared.body.additionalModelRequestFields).toBeUndefined()
+ }),
+ )
+
it.effect("lowers chronological system updates to wrapped user text in order", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
From 24c70ec9740da2a509d6291943afca1dc3cfdb2f Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Sat, 20 Jun 2026 14:54:15 -0500
Subject: [PATCH 015/112] feat(stats): add unique user charts
---
.../stats/app/src/routes/[lab]/[model].tsx | 230 +++++++++++-------
packages/stats/app/src/routes/index.css | 78 ++++--
packages/stats/app/src/routes/index.tsx | 58 ++++-
.../20260620000000_unique_users/migration.sql | 3 +
packages/stats/core/src/database/schema.ts | 1 +
packages/stats/core/src/domain/geo.ts | 1 +
packages/stats/core/src/domain/home.ts | 45 +++-
packages/stats/core/src/domain/inference.ts | 5 +
packages/stats/core/src/domain/model.ts | 3 +
packages/stats/core/src/domain/provider.ts | 1 +
packages/stats/core/src/domain/stat.ts | 4 +
packages/stats/core/src/honeycomb-backfill.ts | 17 +-
packages/stats/core/src/stat-sync.ts | 8 +-
13 files changed, 327 insertions(+), 127 deletions(-)
create mode 100644 packages/stats/core/migrations/20260620000000_unique_users/migration.sql
diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx
index 92f99fe7d3e..a298a7cbbb6 100644
--- a/packages/stats/app/src/routes/[lab]/[model].tsx
+++ b/packages/stats/app/src/routes/[lab]/[model].tsx
@@ -45,6 +45,7 @@ const statsUnfurlUrl = new URL(statsUnfurlPath, statsCanonicalBaseUrl).toString(
const modelHeaderLinks: readonly HeaderLink[] = [
{ href: "#overview", label: "Overview" },
{ href: "#usage", label: "Usage" },
+ { href: "#users", label: "Users" },
{ href: "#efficiency", label: "Efficiency" },
{ href: "#geo-breakdown", label: "Geo Breakdown" },
{ href: "#peers", label: "Peers" },
@@ -175,6 +176,7 @@ export default function StatsModel() {
+
@@ -358,14 +360,6 @@ function ModelOverview(props: { data: StatsModelData | null }) {
}
function ModelUsageSection(props: { data: ModelUsagePoint[] }) {
- const [activeIndex, setActiveIndex] = createSignal()
- const max = createMemo(() => Math.max(0, ...props.data.map((item) => item.tokens)) || 1)
- const activePoint = createMemo(() => {
- const index = activeIndex()
- if (index === undefined) return undefined
- return props.data[index]
- })
-
return (
@@ -373,92 +367,144 @@ function ModelUsageSection(props: { data: ModelUsagePoint[] }) {
when={props.data.some((item) => item.tokens > 0)}
fallback={}
>
- {
- if (event.pointerType === "touch") return
- setActiveIndex(undefined)
- }}
- >
-
-
- {(point, index) => (
-
-
- {formatTokens(point.tokens)}
- {point.date}
-
-
- )}
-
-
-
-
- {(point, index) => (
- {
- if (event.pointerType !== "touch") return
- setActiveIndex(index())
- }}
- onPointerEnter={() => setActiveIndex(index())}
- onPointerMove={(event) => {
- if (event.pointerType === "touch") return
- setActiveIndex(index())
- }}
- onClick={() => setActiveIndex(index())}
- onFocus={() => setActiveIndex(index())}
- onBlur={() => setActiveIndex(undefined)}
- onKeyDown={(event) => {
- if (event.key !== "Enter" && event.key !== " ") return
- event.preventDefault()
- setActiveIndex(index())
- }}
- >
-
-
- {(active) => (
- props.data.length * 0.62 ? "left" : "right"}
- >
-
{active().date}
-
{formatTokens(active().tokens)} tokens
-
-
-
- Daily tokens
-
- {formatTokens(active().tokens)}
-
-
- )}
-
-
- )}
-
-
-
+
)
}
+function ModelUsersSection(props: { data: ModelUsagePoint[] }) {
+ return (
+
+
+ item.users > 0)}
+ fallback={}
+ >
+
+
+
+ )
+}
+
+function ModelColumnChart(props: {
+ data: ModelUsagePoint[]
+ metric: "tokens" | "users"
+ ariaLabel: string
+}) {
+ const [activeIndex, setActiveIndex] = createSignal()
+ const max = createMemo(() => Math.max(0, ...props.data.map((item) => modelUsageMetricValue(item, props.metric))) || 1)
+ const activePoint = createMemo(() => {
+ const index = activeIndex()
+ if (index === undefined) return undefined
+ return props.data[index]
+ })
+
+ return (
+ {
+ if (event.pointerType === "touch") return
+ setActiveIndex(undefined)
+ }}
+ >
+
+
+ {(point, index) => (
+
+
+ {formatModelUsageValue(point, props.metric)}
+ {point.date}
+
+
+ )}
+
+
+
+
+ {(point, index) => (
+ {
+ if (event.pointerType !== "touch") return
+ setActiveIndex(index())
+ }}
+ onPointerEnter={() => setActiveIndex(index())}
+ onPointerMove={(event) => {
+ if (event.pointerType === "touch") return
+ setActiveIndex(index())
+ }}
+ onClick={() => setActiveIndex(index())}
+ onFocus={() => setActiveIndex(index())}
+ onBlur={() => setActiveIndex(undefined)}
+ onKeyDown={(event) => {
+ if (event.key !== "Enter" && event.key !== " ") return
+ event.preventDefault()
+ setActiveIndex(index())
+ }}
+ >
+
+
+ {(active) => (
+ props.data.length * 0.62 ? "left" : "right"}
+ >
+
{active().date}
+
+ {formatModelUsageValue(active(), props.metric)} {modelUsageLabel(props.metric)}
+
+
+
+
+ Daily {modelUsageLabel(props.metric)}
+
+ {formatModelUsageValue(active(), props.metric)}
+
+
+ )}
+
+
+ )}
+
+
+
+ )
+}
+
+function modelUsageMetricValue(point: ModelUsagePoint, metric: "tokens" | "users") {
+ if (metric === "users") return point.users
+ return point.tokens
+}
+
+function formatModelUsageValue(point: ModelUsagePoint, metric: "tokens" | "users") {
+ if (metric === "users") return formatUsers(point.users)
+ return formatTokens(point.tokens)
+}
+
+function modelUsageLabel(metric: "tokens" | "users") {
+ if (metric === "users") return "users"
+ return "tokens"
+}
+
function ModelEfficiencySection(props: { data: StatsModelData | null; catalog: ModelCatalogEntry | null }) {
return (
@@ -834,6 +880,12 @@ function formatInteger(value: number) {
return new Intl.NumberFormat("en").format(value)
}
+function formatUsers(value: number) {
+ if (value >= 1_000_000) return `${trimNumber(value / 1_000_000, value >= 10_000_000 ? 0 : 1)}M`
+ if (value >= 1_000) return `${trimNumber(value / 1_000, value >= 10_000 ? 0 : 1)}K`
+ return formatInteger(Math.round(value))
+}
+
function formatPercent(value: number) {
return `${value.toFixed(value > 0 && value < 10 ? 1 : 0)}%`
}
diff --git a/packages/stats/app/src/routes/index.css b/packages/stats/app/src/routes/index.css
index f6c7f7bef45..05569c1b885 100644
--- a/packages/stats/app/src/routes/index.css
+++ b/packages/stats/app/src/routes/index.css
@@ -1546,7 +1546,7 @@
color: var(--stats-text);
}
-[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] {
+[data-page="stats"] :is([data-section="top-models"], [data-section="unique-users"]) [data-component="chart-tooltip"] {
top: 110px;
box-sizing: border-box;
display: flex;
@@ -1564,40 +1564,53 @@
color: var(--stats-text);
}
-[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"][data-placement="right"] {
+[data-page="stats"]
+ :is([data-section="top-models"], [data-section="unique-users"])
+ [data-component="chart-tooltip"][data-placement="right"] {
right: auto;
left: calc(100% + 8px);
}
-[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"][data-placement="left"] {
+[data-page="stats"]
+ :is([data-section="top-models"], [data-section="unique-users"])
+ [data-component="chart-tooltip"][data-placement="left"] {
right: calc(100% + 8px);
left: auto;
}
-[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] strong,
-[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] > span {
+[data-page="stats"] :is([data-section="top-models"], [data-section="unique-users"]) [data-component="chart-tooltip"] strong,
+[data-page="stats"]
+ :is([data-section="top-models"], [data-section="unique-users"])
+ [data-component="chart-tooltip"]
+ > span {
display: block;
font-size: 11px;
line-height: 12px;
white-space: nowrap;
}
-[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] strong {
+[data-page="stats"] :is([data-section="top-models"], [data-section="unique-users"]) [data-component="chart-tooltip"] strong {
padding: 8px 8px 0;
font-weight: 500;
}
-[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] > span {
+[data-page="stats"]
+ :is([data-section="top-models"], [data-section="unique-users"])
+ [data-component="chart-tooltip"]
+ > span {
padding: 4px 8px 8px;
color: var(--stats-muted);
}
-[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] [data-slot="tooltip-divider"] {
+[data-page="stats"]
+ :is([data-section="top-models"], [data-section="unique-users"])
+ [data-component="chart-tooltip"]
+ [data-slot="tooltip-divider"] {
height: 0.5px;
margin: 0;
}
-[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] p {
+[data-page="stats"] :is([data-section="top-models"], [data-section="unique-users"]) [data-component="chart-tooltip"] p {
grid-template-columns: minmax(0, 1fr) auto;
gap: 4px;
height: 16px;
@@ -1608,36 +1621,50 @@
line-height: 12px;
}
-[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] p[data-muted="true"] {
+[data-page="stats"]
+ :is([data-section="top-models"], [data-section="unique-users"])
+ [data-component="chart-tooltip"]
+ p[data-muted="true"] {
opacity: 0.46;
}
-[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] [data-slot="tooltip-divider"] + p {
+[data-page="stats"]
+ :is([data-section="top-models"], [data-section="unique-users"])
+ [data-component="chart-tooltip"]
+ [data-slot="tooltip-divider"]
+ + p {
margin-top: 8px;
}
-[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] p:last-child {
+[data-page="stats"]
+ :is([data-section="top-models"], [data-section="unique-users"])
+ [data-component="chart-tooltip"]
+ p:last-child {
margin-bottom: 8px;
}
-[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] [data-slot="tooltip-label"] {
+[data-page="stats"]
+ :is([data-section="top-models"], [data-section="unique-users"])
+ [data-component="chart-tooltip"]
+ [data-slot="tooltip-label"] {
grid-template-columns: 16px minmax(0, 1fr);
gap: 4px;
}
-[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] i {
+[data-page="stats"] :is([data-section="top-models"], [data-section="unique-users"]) [data-component="chart-tooltip"] i {
width: 6px;
height: 6px;
justify-self: center;
}
-[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] b {
+[data-page="stats"] :is([data-section="top-models"], [data-section="unique-users"]) [data-component="chart-tooltip"] b {
font-weight: 500;
}
[data-page="stats"]
:is(
[data-section="leaderboard"],
+ [data-section="unique-users"],
[data-section="market-share"],
[data-section="geo-breakdown"],
[data-section="token-cost"],
@@ -3264,10 +3291,12 @@
background: #242424f2;
}
-[data-page="stats"][data-theme="dark"] [data-section="top-models"] [data-component="chart-tooltip"],
+[data-page="stats"][data-theme="dark"]
+ :is([data-section="top-models"], [data-section="unique-users"])
+ [data-component="chart-tooltip"],
:root[data-stats-theme="dark"]
[data-page="stats"]:not([data-theme="light"])
- [data-section="top-models"]
+ :is([data-section="top-models"], [data-section="unique-users"])
[data-component="chart-tooltip"] {
background: #242424f2;
box-shadow:
@@ -3276,10 +3305,13 @@
0 4px 8px #00000052;
}
-[data-page="stats"][data-theme="dark"] [data-section="top-models"] [data-component="chart-tooltip"] > span,
+[data-page="stats"][data-theme="dark"]
+ :is([data-section="top-models"], [data-section="unique-users"])
+ [data-component="chart-tooltip"]
+ > span,
:root[data-stats-theme="dark"]
[data-page="stats"]:not([data-theme="light"])
- [data-section="top-models"]
+ :is([data-section="top-models"], [data-section="unique-users"])
[data-component="chart-tooltip"]
> span {
color: var(--stats-faint);
@@ -3544,6 +3576,7 @@
@media (max-width: 74rem) {
[data-page="stats"] [data-section="top-models"],
[data-page="stats"] [data-section="leaderboard"],
+ [data-page="stats"] [data-section="unique-users"],
[data-page="stats"] [data-section="market-share"],
[data-page="stats"] [data-section="geo-breakdown"],
[data-page="stats"] [data-section="token-cost"],
@@ -3703,6 +3736,7 @@
@media (max-width: 47.999rem) {
[data-page="stats"] [data-section="top-models"],
[data-page="stats"] [data-section="leaderboard"],
+ [data-page="stats"] [data-section="unique-users"],
[data-page="stats"] [data-section="market-share"],
[data-page="stats"] [data-section="geo-breakdown"],
[data-page="stats"] [data-section="token-cost"],
@@ -4011,7 +4045,7 @@
display: block;
}
- [data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] {
+ [data-page="stats"] :is([data-section="top-models"], [data-section="unique-users"]) [data-component="chart-tooltip"] {
position: fixed;
top: auto;
right: 12px;
@@ -4025,7 +4059,9 @@
transform: none;
}
- [data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"][data-placement] {
+ [data-page="stats"]
+ :is([data-section="top-models"], [data-section="unique-users"])
+ [data-component="chart-tooltip"][data-placement] {
right: 12px;
left: 12px;
}
diff --git a/packages/stats/app/src/routes/index.tsx b/packages/stats/app/src/routes/index.tsx
index 7f528015a0f..cdef00b9488 100644
--- a/packages/stats/app/src/routes/index.tsx
+++ b/packages/stats/app/src/routes/index.tsx
@@ -171,6 +171,7 @@ export default function StatsHome() {
<>
+
@@ -598,6 +599,8 @@ function FilterPills(props: {
function TopModelsChart(props: {
data: UsagePoint[]
range: UsageRange
+ metric?: "tokens" | "users"
+ ariaLabel?: string
activeModel: string | undefined
onActiveModelChange: (model: string | undefined) => void
}) {
@@ -606,6 +609,7 @@ function TopModelsChart(props: {
const maxTotal = createMemo(() => getTopModelsMaxTotal(props.data))
const segmentOrder = createMemo(() => getTopModelsSegmentOrder(props.data))
const activePoint = createMemo(() => props.data[activeIndex() ?? -1])
+ const metric = createMemo(() => props.metric ?? "tokens")
createEffect(() => scrollDenseChartToEnd(chartRef, props.range, props.data.length))
@@ -614,9 +618,10 @@ function TopModelsChart(props: {
ref={chartRef}
data-component="top-models-chart"
data-range={props.range}
+ data-metric={metric()}
data-dense-labels={isDenseColumnRange(props.range) ? "true" : undefined}
role="img"
- aria-label="Stacked top model usage chart"
+ aria-label={props.ariaLabel ?? "Stacked top model usage chart"}
style={{ "--top-models-count": props.data.length } as JSX.CSSProperties}
onPointerLeave={(event) => {
if (event.pointerType === "touch") return
@@ -633,7 +638,7 @@ function TopModelsChart(props: {
data-mobile-hidden={isTopModelsMobileAxisHidden(index(), props.data.length) ? "true" : undefined}
>
- {formatTokens(usageTotal(day))}
+ {formatUsageChartValue(usageTotal(day), metric())}
{day.date}
{formatTopModelsMobileDate(day.date, props.range)}
@@ -657,7 +662,7 @@ function TopModelsChart(props: {
data-slot="top-models-bar"
role="button"
tabIndex={0}
- aria-label={`${day.date} ${formatTokens(usageTotal(day))}`}
+ aria-label={`${day.date} ${formatUsageChartValue(usageTotal(day), metric())} ${usageChartTotalLabel(metric())}`}
data-active={activeIndex() === dayIndex() ? "true" : undefined}
data-muted={activeIndex() !== undefined && activeIndex() !== dayIndex() ? "true" : undefined}
style={{ "--top-models-bar-height": `${getTopModelsBarHeight(usageTotal(day), maxTotal())}%` }}
@@ -739,7 +744,9 @@ function TopModelsChart(props: {
data-placement={dayIndex() > props.data.length * 0.62 ? "left" : "right"}
>
{point().date}
- {formatTokens(usageTotal(point()))} total
+
+ {formatUsageChartValue(usageTotal(point()), metric())} {usageChartTotalLabel(metric())}
+
{(item) => (
@@ -759,7 +766,7 @@ function TopModelsChart(props: {
/>{" "}
{item.segment.model}
- {formatTokens(item.segment.value)}
+ {formatUsageChartValue(item.segment.value, metric())}
)}
@@ -774,6 +781,31 @@ function TopModelsChart(props: {
)
}
+function UniqueUsersSection(props: { data: StatsHomeData["users"] }) {
+ const [activeModel, setActiveModel] = createSignal()
+ const data = createMemo(() => props.data.Go["2M"])
+
+ return (
+
+
+
+ usageTotal(item) > 0)}
+ fallback={}
+ >
+
+
+
+ )
+}
+
function isTopModelsBlankHover(bar: HTMLElement, clientY: number) {
const stack = bar.querySelector('[data-slot="top-models-stack"]')
if (!stack) return true
@@ -864,6 +896,22 @@ function formatTokens(value: number) {
return `${Math.round(value * 1000)}B`
}
+function formatUsageChartValue(value: number, metric: "tokens" | "users") {
+ if (metric === "users") return formatUsers(value)
+ return formatTokens(value)
+}
+
+function usageChartTotalLabel(metric: "tokens" | "users") {
+ if (metric === "users") return "model users"
+ return "total"
+}
+
+function formatUsers(value: number) {
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M`
+ if (value >= 1_000) return `${(value / 1_000).toFixed(value >= 10_000 ? 0 : 1)}K`
+ return new Intl.NumberFormat("en").format(Math.round(value))
+}
+
function Leaderboard(props: {
data: LeaderboardEntry[]
activeModel: string | undefined
diff --git a/packages/stats/core/migrations/20260620000000_unique_users/migration.sql b/packages/stats/core/migrations/20260620000000_unique_users/migration.sql
new file mode 100644
index 00000000000..3b9784b0dc4
--- /dev/null
+++ b/packages/stats/core/migrations/20260620000000_unique_users/migration.sql
@@ -0,0 +1,3 @@
+ALTER TABLE `geo_stat` ADD `unique_users` bigint NOT NULL DEFAULT 0;--> statement-breakpoint
+ALTER TABLE `model_stat` ADD `unique_users` bigint NOT NULL DEFAULT 0;--> statement-breakpoint
+ALTER TABLE `provider_stat` ADD `unique_users` bigint NOT NULL DEFAULT 0;
diff --git a/packages/stats/core/src/database/schema.ts b/packages/stats/core/src/database/schema.ts
index a658a4d511c..d5bfa314bfd 100644
--- a/packages/stats/core/src/database/schema.ts
+++ b/packages/stats/core/src/database/schema.ts
@@ -123,6 +123,7 @@ function metricColumns() {
return {
sessions: bigint({ mode: "number" }).notNull().default(0),
requests: bigint({ mode: "number" }).notNull().default(0),
+ unique_users: bigint({ mode: "number" }).notNull().default(0),
input_tokens: bigint({ mode: "number" }).notNull().default(0),
output_tokens: bigint({ mode: "number" }).notNull().default(0),
reasoning_tokens: bigint({ mode: "number" }).notNull().default(0),
diff --git a/packages/stats/core/src/domain/geo.ts b/packages/stats/core/src/domain/geo.ts
index c58195c7795..99c7bcc4acb 100644
--- a/packages/stats/core/src/domain/geo.ts
+++ b/packages/stats/core/src/domain/geo.ts
@@ -145,6 +145,7 @@ export class GeoStatRepo extends Context.Service>
+ users: Record>
leaderboard: Record>
market: Record
tokenCost: Record
@@ -118,6 +119,7 @@ type ModelAggregate = {
model: string
provider: string
sessions: number
+ uniqueUsers: number
inputTokens: number
outputTokens: number
reasoningTokens: number
@@ -200,6 +202,18 @@ function buildStatsHomeData(
),
),
),
+ users: createUsageProductRecord((product) =>
+ createRangeRecord((range) =>
+ buildUsagePoints(
+ normalized,
+ product,
+ range,
+ getWindow(range, earliest, latest),
+ getWindow("1W", earliest, latest),
+ "users",
+ ),
+ ),
+ ),
leaderboard: createUsageProductRecord((product) =>
createRangeRecord((range) => buildLeaderboard(normalized, product, getWindow("1W", earliest, latest))),
),
@@ -340,6 +354,7 @@ function emptyStatsHomeData(): StatsHomeData {
return {
updatedAt: null,
usage: createUsageProductRecord(() => createRangeRecord(() => [])),
+ users: createUsageProductRecord(() => createRangeRecord(() => [])),
leaderboard: createUsageProductRecord(() => createRangeRecord(() => [])),
market: createRangeRecord(() => []),
tokenCost: createTokenProductRecord(() => []),
@@ -355,28 +370,39 @@ function buildUsagePoints(
range: UsageRange,
window: DateWindow,
rankWindow: DateWindow,
+ metric: "tokens" | "users" = "tokens",
) {
const modelOrder = aggregateByModelName(rowsForProduct(rows, product, rankWindow.start, rankWindow.end))
- .toSorted((a, b) => b.totalTokens - a.totalTokens)
+ .toSorted((a, b) => modelUsageValue(b, metric) - modelUsageValue(a, metric))
.slice(0, TOP_MODEL_SEGMENT_LIMIT)
.map((item) => item.model)
return createBuckets(window, range).map((bucket) => {
const bucketRows = aggregateByModelName(rowsForProduct(rows, product, bucket.start, bucket.end))
- const byModel = new Map(bucketRows.map((item) => [item.model, item.totalTokens]))
- const segmentTokens = modelOrder.map((model) => ({ model, tokens: byModel.get(model) ?? 0 }))
- const knownTokens = segmentTokens.reduce((sum, item) => sum + item.tokens, 0)
- const totalTokens = bucketRows.reduce((sum, item) => sum + item.totalTokens, 0)
+ const byModel = new Map(bucketRows.map((item) => [item.model, modelUsageValue(item, metric)]))
+ const segments = modelOrder.map((model) => ({ model, value: byModel.get(model) ?? 0 }))
+ const knownValue = segments.reduce((sum, item) => sum + item.value, 0)
+ const totalValue = bucketRows.reduce((sum, item) => sum + modelUsageValue(item, metric), 0)
return {
date: bucket.label,
segments: [
- ...segmentTokens.map((item) => ({ model: item.model, value: round(item.tokens / 1_000_000_000_000, 4) })),
- { model: "Other", value: round(Math.max(totalTokens - knownTokens, 0) / 1_000_000_000_000, 4) },
+ ...segments.map((item) => ({ model: item.model, value: usagePointValue(item.value, metric) })),
+ { model: "Other", value: usagePointValue(Math.max(totalValue - knownValue, 0), metric) },
],
}
})
}
+function modelUsageValue(item: ModelAggregate, metric: "tokens" | "users") {
+ if (metric === "users") return item.uniqueUsers
+ return item.totalTokens
+}
+
+function usagePointValue(value: number, metric: "tokens" | "users") {
+ if (metric === "users") return value
+ return round(value / 1_000_000_000_000, 4)
+}
+
function buildLeaderboard(rows: StatMetricRow[], product: UsageProduct, rankWindow: DateWindow) {
const previous = new Map(
aggregateByModelName(rowsForProduct(rows, product, rankWindow.previousStart, rankWindow.previousEnd)).map(
@@ -502,6 +528,7 @@ function buildModelUsage(rows: StatMetricRow[], window: DateWindow, range: Usage
return {
date: bucket.label,
tokens: aggregate.totalTokens,
+ users: aggregate.uniqueUsers,
sessions: aggregate.sessions,
cost: round(microcentsToDollars(aggregate.totalCostMicrocents), 2),
}
@@ -601,6 +628,7 @@ function combineRowsForModel(model: string, rows: StatMetricRow[]): ModelAggrega
model,
provider: "unknown",
sessions: 0,
+ uniqueUsers: 0,
inputTokens: 0,
outputTokens: 0,
reasoningTokens: 0,
@@ -617,6 +645,7 @@ function combineModelAggregate(current: ModelAggregate | undefined, row: StatMet
model: row.model,
provider: row.provider,
sessions: (current?.sessions ?? 0) + row.sessions,
+ uniqueUsers: (current?.uniqueUsers ?? 0) + row.uniqueUsers,
inputTokens: (current?.inputTokens ?? 0) + row.inputTokens,
outputTokens: (current?.outputTokens ?? 0) + row.outputTokens,
reasoningTokens: (current?.reasoningTokens ?? 0) + row.reasoningTokens,
diff --git a/packages/stats/core/src/domain/inference.ts b/packages/stats/core/src/domain/inference.ts
index 9a74daf958e..a7e4c0037e8 100644
--- a/packages/stats/core/src/domain/inference.ts
+++ b/packages/stats/core/src/domain/inference.ts
@@ -40,6 +40,7 @@ export function buildStatsQuery(periodStart: Date, periodEnd: Date, dimension: S
const aggregateColumns = `
COUNT(DISTINCT session) AS sessions,
COUNT(*) AS requests,
+ COUNT(DISTINCT user_key) AS unique_users,
COALESCE(SUM(tokens_input), 0) AS input_tokens,
COALESCE(SUM(tokens_output), 0) AS output_tokens,
COALESCE(SUM(tokens_reasoning), 0) AS reasoning_tokens,
@@ -70,6 +71,8 @@ WITH normalized AS (
UPPER(COALESCE(NULLIF(cf_country, ''), 'ZZ')) AS country,
COALESCE(NULLIF(cf_continent, ''), '') AS continent,
session,
+ COALESCE(NULLIF(workspace, ''), '') AS workspace,
+ COALESCE(NULLIF(api_key, ''), '') AS api_key,
status,
duration AS duration_ms,
time_to_first_byte AS ttfb_ms,
@@ -108,6 +111,7 @@ WITH normalized AS (
country,
continent,
session,
+ COALESCE(NULLIF(workspace, ''), NULLIF(api_key, '')) AS user_key,
status,
duration_ms,
ttfb_ms,
@@ -197,6 +201,7 @@ function toStatBaseAggregate(data: AthenaData): StatBaseAggregate[] {
tier: normalizeTier(data.tier || "unknown"),
sessions: integer(data, "sessions"),
requests: integer(data, "requests"),
+ unique_users: integer(data, "unique_users"),
input_tokens: integer(data, "input_tokens"),
output_tokens: integer(data, "output_tokens"),
reasoning_tokens: integer(data, "reasoning_tokens"),
diff --git a/packages/stats/core/src/domain/model.ts b/packages/stats/core/src/domain/model.ts
index 0d40a51c501..912ee629046 100644
--- a/packages/stats/core/src/domain/model.ts
+++ b/packages/stats/core/src/domain/model.ts
@@ -27,6 +27,7 @@ export type ModelStatMetric = {
provider: string
model: string
sessions: number
+ uniqueUsers: number
inputTokens: number
outputTokens: number
reasoningTokens: number
@@ -64,6 +65,7 @@ export class ModelStatRepo extends Context.Service(left: T, right: T): T {
...left,
sessions: (left.sessions ?? 0) + (right.sessions ?? 0),
requests: (left.requests ?? 0) + (right.requests ?? 0),
+ unique_users: (left.unique_users ?? 0) + (right.unique_users ?? 0),
input_tokens: (left.input_tokens ?? 0) + (right.input_tokens ?? 0),
output_tokens: (left.output_tokens ?? 0) + (right.output_tokens ?? 0),
reasoning_tokens: (left.reasoning_tokens ?? 0) + (right.reasoning_tokens ?? 0),
diff --git a/packages/stats/core/src/honeycomb-backfill.ts b/packages/stats/core/src/honeycomb-backfill.ts
index 72f9c874eb9..5a2b25fa4c7 100644
--- a/packages/stats/core/src/honeycomb-backfill.ts
+++ b/packages/stats/core/src/honeycomb-backfill.ts
@@ -242,6 +242,7 @@ function metricQuery(breakdowns: string[], limit: number, filters: ReturnType) {
- return ["sumtokens", "sumtokensinput", "inputtokens", "totaltokens", "avgduration", "countdistinctsession"].some(
- (header) => headers.has(header),
- )
+ return [
+ "sumtokens",
+ "sumtokensinput",
+ "inputtokens",
+ "totaltokens",
+ "avgduration",
+ "countdistinctsession",
+ "countdistinctworkspace",
+ ].some((header) => headers.has(header))
}
function hasHeader(headers: Set, names: string[]) {
@@ -447,6 +454,7 @@ function baseAggregate(row: RawRow, grain: Grain, opts: ImportOptions): StatBase
tier: tier(row),
sessions: integer(row, "sessions", ["COUNT_DISTINCT(session)"]),
requests: integer(row, "requests", ["COUNT", "COUNT()"]),
+ unique_users: integer(row, "unique_users", ["COUNT_DISTINCT(workspace)", "COUNT_DISTINCT(api_key)"]),
input_tokens: integer(row, "input_tokens", ["SUM(tokens.input)", "SUM(tokens_input)"]),
output_tokens: integer(row, "output_tokens", ["SUM(tokens.output)", "SUM(tokens_output)"]),
reasoning_tokens: integer(row, "reasoning_tokens", ["SUM(tokens.reasoning)", "SUM(tokens_reasoning)"]),
@@ -808,6 +816,7 @@ async function upsertModelRows(db: ReturnType, rows: ModelStatRo
provider_model: inserted("provider_model"),
sessions: inserted("sessions"),
requests: inserted("requests"),
+ unique_users: inserted("unique_users"),
input_tokens: inserted("input_tokens"),
output_tokens: inserted("output_tokens"),
reasoning_tokens: inserted("reasoning_tokens"),
@@ -845,6 +854,7 @@ async function upsertProviderRows(db: ReturnType, rows: Provider
set: {
sessions: inserted("sessions"),
requests: inserted("requests"),
+ unique_users: inserted("unique_users"),
input_tokens: inserted("input_tokens"),
output_tokens: inserted("output_tokens"),
reasoning_tokens: inserted("reasoning_tokens"),
@@ -887,6 +897,7 @@ async function upsertGeoRows(db: ReturnType, rows: GeoStatRow[],
continent: inserted("continent"),
sessions: inserted("sessions"),
requests: inserted("requests"),
+ unique_users: inserted("unique_users"),
input_tokens: inserted("input_tokens"),
output_tokens: inserted("output_tokens"),
reasoning_tokens: inserted("reasoning_tokens"),
diff --git a/packages/stats/core/src/stat-sync.ts b/packages/stats/core/src/stat-sync.ts
index df0a317d6cf..86e5ded7deb 100644
--- a/packages/stats/core/src/stat-sync.ts
+++ b/packages/stats/core/src/stat-sync.ts
@@ -11,6 +11,7 @@ import { startOfIsoWeek } from "./domain/stat"
const DATALAKE_INGESTION_LAG_MS = 5 * 60_000
const STATS_DATA_START_MS = new Date("2026-05-28T00:00:00.000Z").getTime()
const WEEK_MS = 7 * 86_400_000
+const DISPLAY_WINDOW_MS = 56 * 86_400_000
export type SyncStatsResult = { ok: true; rows: number; startedAt: string; periodStart: string; periodEnd: string }
export type SyncStatsError = AthenaQueryError | AthenaQueryTimeoutError | DatabaseError
@@ -23,7 +24,12 @@ export const syncStats: () => Effect.Effect<
const startedAt = yield* DateTime.nowAsDate
const periodEnd = new Date(Math.floor((startedAt.getTime() - DATALAKE_INGESTION_LAG_MS) / 60_000) * 60_000)
// May 27 was partial, so keep Athena stats anchored at the first complete day.
- const periodStart = new Date(Math.max(startOfIsoWeek(periodEnd).getTime() - WEEK_MS, STATS_DATA_START_MS))
+ const periodStart = new Date(
+ Math.max(
+ Math.min(startOfIsoWeek(periodEnd).getTime() - WEEK_MS, periodEnd.getTime() - DISPLAY_WINDOW_MS),
+ STATS_DATA_START_MS,
+ ),
+ )
const athena = yield* Athena
const modelStats = yield* ModelStatRepo
const providerStats = yield* ProviderStatRepo
From 1c76587ce2057893c5f2ad24b0955bb2ef982329 Mon Sep 17 00:00:00 2001
From: "opencode-agent[bot]"
Date: Sat, 20 Jun 2026 19:56:12 +0000
Subject: [PATCH 016/112] chore: generate
---
.../stats/app/src/routes/[lab]/[model].tsx | 23 +++++++++++--------
packages/stats/app/src/routes/index.css | 10 ++++++--
packages/stats/app/src/routes/index.tsx | 4 +++-
3 files changed, 24 insertions(+), 13 deletions(-)
diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx
index a298a7cbbb6..6869764f333 100644
--- a/packages/stats/app/src/routes/[lab]/[model].tsx
+++ b/packages/stats/app/src/routes/[lab]/[model].tsx
@@ -376,10 +376,15 @@ function ModelUsageSection(props: { data: ModelUsagePoint[] }) {
function ModelUsersSection(props: { data: ModelUsagePoint[] }) {
return (
-
+
item.users > 0)}
- fallback={}
+ fallback={
+
+ }
>
@@ -387,11 +392,7 @@ function ModelUsersSection(props: { data: ModelUsagePoint[] }) {
)
}
-function ModelColumnChart(props: {
- data: ModelUsagePoint[]
- metric: "tokens" | "users"
- ariaLabel: string
-}) {
+function ModelColumnChart(props: { data: ModelUsagePoint[]; metric: "tokens" | "users"; ariaLabel: string }) {
const [activeIndex, setActiveIndex] = createSignal()
const max = createMemo(() => Math.max(0, ...props.data.map((item) => modelUsageMetricValue(item, props.metric))) || 1)
const activePoint = createMemo(() => {
@@ -458,9 +459,11 @@ function ModelColumnChart(props: {
>
{(active) => (
diff --git a/packages/stats/app/src/routes/index.css b/packages/stats/app/src/routes/index.css
index 05569c1b885..01b561c526d 100644
--- a/packages/stats/app/src/routes/index.css
+++ b/packages/stats/app/src/routes/index.css
@@ -1578,7 +1578,10 @@
left: auto;
}
-[data-page="stats"] :is([data-section="top-models"], [data-section="unique-users"]) [data-component="chart-tooltip"] strong,
+[data-page="stats"]
+ :is([data-section="top-models"], [data-section="unique-users"])
+ [data-component="chart-tooltip"]
+ strong,
[data-page="stats"]
:is([data-section="top-models"], [data-section="unique-users"])
[data-component="chart-tooltip"]
@@ -1589,7 +1592,10 @@
white-space: nowrap;
}
-[data-page="stats"] :is([data-section="top-models"], [data-section="unique-users"]) [data-component="chart-tooltip"] strong {
+[data-page="stats"]
+ :is([data-section="top-models"], [data-section="unique-users"])
+ [data-component="chart-tooltip"]
+ strong {
padding: 8px 8px 0;
font-weight: 500;
}
diff --git a/packages/stats/app/src/routes/index.tsx b/packages/stats/app/src/routes/index.tsx
index cdef00b9488..862120d033f 100644
--- a/packages/stats/app/src/routes/index.tsx
+++ b/packages/stats/app/src/routes/index.tsx
@@ -791,7 +791,9 @@ function UniqueUsersSection(props: { data: StatsHomeData["users"] }) {
usageTotal(item) > 0)}
- fallback={}
+ fallback={
+
+ }
>
Date: Sat, 20 Jun 2026 15:56:36 -0500
Subject: [PATCH 017/112] fix(stats): tolerate pending user column
---
packages/stats/core/src/domain/geo.ts | 86 ++++++-----
packages/stats/core/src/domain/model.ts | 165 +++++++++++++--------
packages/stats/core/src/domain/provider.ts | 84 ++++++-----
packages/stats/core/src/domain/stat.ts | 21 +++
4 files changed, 221 insertions(+), 135 deletions(-)
diff --git a/packages/stats/core/src/domain/geo.ts b/packages/stats/core/src/domain/geo.ts
index 99c7bcc4acb..b75e08a34b2 100644
--- a/packages/stats/core/src/domain/geo.ts
+++ b/packages/stats/core/src/domain/geo.ts
@@ -8,6 +8,8 @@ import {
chunks,
collapseRows,
inserted,
+ isMissingUniqueUsersColumn,
+ omitUniqueUsers,
rankRowsWithMarketShare,
statPeriodKey,
statRowScope,
@@ -136,49 +138,59 @@ export class GeoStatRepo extends Context.Service
Effect.tryPromise({
- try: () =>
- db
- .insert(geoStat)
- .values(chunk)
- .onDuplicateKeyUpdate({
- set: {
- continent: inserted("continent"),
- sessions: inserted("sessions"),
- requests: inserted("requests"),
- unique_users: inserted("unique_users"),
- input_tokens: inserted("input_tokens"),
- output_tokens: inserted("output_tokens"),
- reasoning_tokens: inserted("reasoning_tokens"),
- cache_read_tokens: inserted("cache_read_tokens"),
- total_tokens: inserted("total_tokens"),
- input_cost_microcents: inserted("input_cost_microcents"),
- output_cost_microcents: inserted("output_cost_microcents"),
- total_cost_microcents: inserted("total_cost_microcents"),
- avg_duration_ms: inserted("avg_duration_ms"),
- p50_duration_ms: inserted("p50_duration_ms"),
- p95_duration_ms: inserted("p95_duration_ms"),
- avg_ttfb_ms: inserted("avg_ttfb_ms"),
- p50_ttfb_ms: inserted("p50_ttfb_ms"),
- p95_ttfb_ms: inserted("p95_ttfb_ms"),
- avg_output_tps: inserted("avg_output_tps"),
- success_count: inserted("success_count"),
- error_count: inserted("error_count"),
- sample_count: inserted("sample_count"),
- market_share_tokens: inserted("market_share_tokens"),
- market_share_requests: inserted("market_share_requests"),
- market_share_sessions: inserted("market_share_sessions"),
- rank_by_tokens: inserted("rank_by_tokens"),
- rank_by_requests: inserted("rank_by_requests"),
- rank_by_sessions: inserted("rank_by_sessions"),
- rank_by_cost: inserted("rank_by_cost"),
- },
- }),
+ try: async () => {
+ try {
+ return await upsertGeoChunk(chunk, true)
+ } catch (cause) {
+ if (!isMissingUniqueUsersColumn(cause)) throw cause
+ return upsertGeoChunk(chunk, false)
+ }
+ },
catch: (cause) => DatabaseError.make({ cause }),
}),
{ discard: true },
)
})
+ function upsertGeoChunk(chunk: GeoStatRow[], includeUniqueUsers: boolean) {
+ return db
+ .insert(geoStat)
+ .values(includeUniqueUsers ? chunk : omitUniqueUsers(chunk))
+ .onDuplicateKeyUpdate({
+ set: {
+ continent: inserted("continent"),
+ sessions: inserted("sessions"),
+ requests: inserted("requests"),
+ ...(includeUniqueUsers ? { unique_users: inserted("unique_users") } : {}),
+ input_tokens: inserted("input_tokens"),
+ output_tokens: inserted("output_tokens"),
+ reasoning_tokens: inserted("reasoning_tokens"),
+ cache_read_tokens: inserted("cache_read_tokens"),
+ total_tokens: inserted("total_tokens"),
+ input_cost_microcents: inserted("input_cost_microcents"),
+ output_cost_microcents: inserted("output_cost_microcents"),
+ total_cost_microcents: inserted("total_cost_microcents"),
+ avg_duration_ms: inserted("avg_duration_ms"),
+ p50_duration_ms: inserted("p50_duration_ms"),
+ p95_duration_ms: inserted("p95_duration_ms"),
+ avg_ttfb_ms: inserted("avg_ttfb_ms"),
+ p50_ttfb_ms: inserted("p50_ttfb_ms"),
+ p95_ttfb_ms: inserted("p95_ttfb_ms"),
+ avg_output_tps: inserted("avg_output_tps"),
+ success_count: inserted("success_count"),
+ error_count: inserted("error_count"),
+ sample_count: inserted("sample_count"),
+ market_share_tokens: inserted("market_share_tokens"),
+ market_share_requests: inserted("market_share_requests"),
+ market_share_sessions: inserted("market_share_sessions"),
+ rank_by_tokens: inserted("rank_by_tokens"),
+ rank_by_requests: inserted("rank_by_requests"),
+ rank_by_sessions: inserted("rank_by_sessions"),
+ rank_by_cost: inserted("rank_by_cost"),
+ },
+ })
+ }
+
const deleteRetiredDimensions = Effect.fn("GeoStatRepo.deleteRetiredDimensions")(function* (rows: GeoStatRow[]) {
const scope = statRowScope(rows)
if (!scope) return
diff --git a/packages/stats/core/src/domain/model.ts b/packages/stats/core/src/domain/model.ts
index 912ee629046..5cdee912b76 100644
--- a/packages/stats/core/src/domain/model.ts
+++ b/packages/stats/core/src/domain/model.ts
@@ -8,6 +8,8 @@ import {
chunks,
collapseRows,
inserted,
+ isMissingUniqueUsersColumn,
+ omitUniqueUsers,
rankBy,
statPeriodKey,
statRowScope,
@@ -56,35 +58,55 @@ export class ModelStatRepo extends Context.Service
- db
- .select({
- periodKey: modelStat.period_key,
- updatedAt: modelStat.updated_at,
- tier: modelStat.tier,
- provider: modelStat.provider,
- model: modelStat.model,
- sessions: modelStat.sessions,
- uniqueUsers: modelStat.unique_users,
- inputTokens: modelStat.input_tokens,
- outputTokens: modelStat.output_tokens,
- reasoningTokens: modelStat.reasoning_tokens,
- cacheReadTokens: modelStat.cache_read_tokens,
- totalTokens: modelStat.total_tokens,
- inputCostMicrocents: modelStat.input_cost_microcents,
- outputCostMicrocents: modelStat.output_cost_microcents,
- totalCostMicrocents: modelStat.total_cost_microcents,
- })
- .from(modelStat)
- .where(
- and(
- eq(modelStat.grain, "day"),
- eq(modelStat.client, "all"),
- eq(modelStat.source, "all"),
- inArray(modelStat.tier, ["Go", "go"]),
- ),
- )
- .orderBy(asc(modelStat.period_key)),
+ try: async () => {
+ try {
+ return await db
+ .select({
+ periodKey: modelStat.period_key,
+ updatedAt: modelStat.updated_at,
+ tier: modelStat.tier,
+ provider: modelStat.provider,
+ model: modelStat.model,
+ sessions: modelStat.sessions,
+ uniqueUsers: modelStat.unique_users,
+ inputTokens: modelStat.input_tokens,
+ outputTokens: modelStat.output_tokens,
+ reasoningTokens: modelStat.reasoning_tokens,
+ cacheReadTokens: modelStat.cache_read_tokens,
+ totalTokens: modelStat.total_tokens,
+ inputCostMicrocents: modelStat.input_cost_microcents,
+ outputCostMicrocents: modelStat.output_cost_microcents,
+ totalCostMicrocents: modelStat.total_cost_microcents,
+ })
+ .from(modelStat)
+ .where(modelDailyScope())
+ .orderBy(asc(modelStat.period_key))
+ } catch (cause) {
+ if (!isMissingUniqueUsersColumn(cause)) throw cause
+ return (
+ await db
+ .select({
+ periodKey: modelStat.period_key,
+ updatedAt: modelStat.updated_at,
+ tier: modelStat.tier,
+ provider: modelStat.provider,
+ model: modelStat.model,
+ sessions: modelStat.sessions,
+ inputTokens: modelStat.input_tokens,
+ outputTokens: modelStat.output_tokens,
+ reasoningTokens: modelStat.reasoning_tokens,
+ cacheReadTokens: modelStat.cache_read_tokens,
+ totalTokens: modelStat.total_tokens,
+ inputCostMicrocents: modelStat.input_cost_microcents,
+ outputCostMicrocents: modelStat.output_cost_microcents,
+ totalCostMicrocents: modelStat.total_cost_microcents,
+ })
+ .from(modelStat)
+ .where(modelDailyScope())
+ .orderBy(asc(modelStat.period_key))
+ ).map((row) => ({ ...row, uniqueUsers: 0 }))
+ }
+ },
catch: (cause) => DatabaseError.make({ cause }),
})
})
@@ -94,45 +116,55 @@ export class ModelStatRepo extends Context.Service
Effect.tryPromise({
- try: () =>
- db
- .insert(modelStat)
- .values(chunk)
- .onDuplicateKeyUpdate({
- set: {
- provider_model: inserted("provider_model"),
- sessions: inserted("sessions"),
- requests: inserted("requests"),
- unique_users: inserted("unique_users"),
- input_tokens: inserted("input_tokens"),
- output_tokens: inserted("output_tokens"),
- reasoning_tokens: inserted("reasoning_tokens"),
- cache_read_tokens: inserted("cache_read_tokens"),
- total_tokens: inserted("total_tokens"),
- input_cost_microcents: inserted("input_cost_microcents"),
- output_cost_microcents: inserted("output_cost_microcents"),
- total_cost_microcents: inserted("total_cost_microcents"),
- avg_duration_ms: inserted("avg_duration_ms"),
- p50_duration_ms: inserted("p50_duration_ms"),
- p95_duration_ms: inserted("p95_duration_ms"),
- avg_ttfb_ms: inserted("avg_ttfb_ms"),
- p50_ttfb_ms: inserted("p50_ttfb_ms"),
- p95_ttfb_ms: inserted("p95_ttfb_ms"),
- avg_output_tps: inserted("avg_output_tps"),
- success_count: inserted("success_count"),
- error_count: inserted("error_count"),
- sample_count: inserted("sample_count"),
- rank_by_tokens: inserted("rank_by_tokens"),
- rank_by_requests: inserted("rank_by_requests"),
- rank_by_cost: inserted("rank_by_cost"),
- },
- }),
+ try: async () => {
+ try {
+ return await upsertModelChunk(chunk, true)
+ } catch (cause) {
+ if (!isMissingUniqueUsersColumn(cause)) throw cause
+ return upsertModelChunk(chunk, false)
+ }
+ },
catch: (cause) => DatabaseError.make({ cause }),
}),
{ discard: true },
)
})
+ function upsertModelChunk(chunk: ModelStatRow[], includeUniqueUsers: boolean) {
+ return db
+ .insert(modelStat)
+ .values(includeUniqueUsers ? chunk : omitUniqueUsers(chunk))
+ .onDuplicateKeyUpdate({
+ set: {
+ provider_model: inserted("provider_model"),
+ sessions: inserted("sessions"),
+ requests: inserted("requests"),
+ ...(includeUniqueUsers ? { unique_users: inserted("unique_users") } : {}),
+ input_tokens: inserted("input_tokens"),
+ output_tokens: inserted("output_tokens"),
+ reasoning_tokens: inserted("reasoning_tokens"),
+ cache_read_tokens: inserted("cache_read_tokens"),
+ total_tokens: inserted("total_tokens"),
+ input_cost_microcents: inserted("input_cost_microcents"),
+ output_cost_microcents: inserted("output_cost_microcents"),
+ total_cost_microcents: inserted("total_cost_microcents"),
+ avg_duration_ms: inserted("avg_duration_ms"),
+ p50_duration_ms: inserted("p50_duration_ms"),
+ p95_duration_ms: inserted("p95_duration_ms"),
+ avg_ttfb_ms: inserted("avg_ttfb_ms"),
+ p50_ttfb_ms: inserted("p50_ttfb_ms"),
+ p95_ttfb_ms: inserted("p95_ttfb_ms"),
+ avg_output_tps: inserted("avg_output_tps"),
+ success_count: inserted("success_count"),
+ error_count: inserted("error_count"),
+ sample_count: inserted("sample_count"),
+ rank_by_tokens: inserted("rank_by_tokens"),
+ rank_by_requests: inserted("rank_by_requests"),
+ rank_by_cost: inserted("rank_by_cost"),
+ },
+ })
+ }
+
const deleteRetiredDimensions = Effect.fn("ModelStatRepo.deleteRetiredDimensions")(function* (
rows: ModelStatRow[],
) {
@@ -165,6 +197,15 @@ export class ModelStatRepo extends Context.Service
Effect.tryPromise({
- try: () =>
- db
- .insert(providerStat)
- .values(chunk)
- .onDuplicateKeyUpdate({
- set: {
- sessions: inserted("sessions"),
- requests: inserted("requests"),
- unique_users: inserted("unique_users"),
- input_tokens: inserted("input_tokens"),
- output_tokens: inserted("output_tokens"),
- reasoning_tokens: inserted("reasoning_tokens"),
- cache_read_tokens: inserted("cache_read_tokens"),
- total_tokens: inserted("total_tokens"),
- input_cost_microcents: inserted("input_cost_microcents"),
- output_cost_microcents: inserted("output_cost_microcents"),
- total_cost_microcents: inserted("total_cost_microcents"),
- avg_duration_ms: inserted("avg_duration_ms"),
- p50_duration_ms: inserted("p50_duration_ms"),
- p95_duration_ms: inserted("p95_duration_ms"),
- avg_ttfb_ms: inserted("avg_ttfb_ms"),
- p50_ttfb_ms: inserted("p50_ttfb_ms"),
- p95_ttfb_ms: inserted("p95_ttfb_ms"),
- avg_output_tps: inserted("avg_output_tps"),
- success_count: inserted("success_count"),
- error_count: inserted("error_count"),
- sample_count: inserted("sample_count"),
- market_share_tokens: inserted("market_share_tokens"),
- market_share_requests: inserted("market_share_requests"),
- market_share_sessions: inserted("market_share_sessions"),
- rank_by_tokens: inserted("rank_by_tokens"),
- rank_by_requests: inserted("rank_by_requests"),
- rank_by_sessions: inserted("rank_by_sessions"),
- rank_by_cost: inserted("rank_by_cost"),
- },
- }),
+ try: async () => {
+ try {
+ return await upsertProviderChunk(chunk, true)
+ } catch (cause) {
+ if (!isMissingUniqueUsersColumn(cause)) throw cause
+ return upsertProviderChunk(chunk, false)
+ }
+ },
catch: (cause) => DatabaseError.make({ cause }),
}),
{ discard: true },
)
})
+ function upsertProviderChunk(chunk: ProviderStatRow[], includeUniqueUsers: boolean) {
+ return db
+ .insert(providerStat)
+ .values(includeUniqueUsers ? chunk : omitUniqueUsers(chunk))
+ .onDuplicateKeyUpdate({
+ set: {
+ sessions: inserted("sessions"),
+ requests: inserted("requests"),
+ ...(includeUniqueUsers ? { unique_users: inserted("unique_users") } : {}),
+ input_tokens: inserted("input_tokens"),
+ output_tokens: inserted("output_tokens"),
+ reasoning_tokens: inserted("reasoning_tokens"),
+ cache_read_tokens: inserted("cache_read_tokens"),
+ total_tokens: inserted("total_tokens"),
+ input_cost_microcents: inserted("input_cost_microcents"),
+ output_cost_microcents: inserted("output_cost_microcents"),
+ total_cost_microcents: inserted("total_cost_microcents"),
+ avg_duration_ms: inserted("avg_duration_ms"),
+ p50_duration_ms: inserted("p50_duration_ms"),
+ p95_duration_ms: inserted("p95_duration_ms"),
+ avg_ttfb_ms: inserted("avg_ttfb_ms"),
+ p50_ttfb_ms: inserted("p50_ttfb_ms"),
+ p95_ttfb_ms: inserted("p95_ttfb_ms"),
+ avg_output_tps: inserted("avg_output_tps"),
+ success_count: inserted("success_count"),
+ error_count: inserted("error_count"),
+ sample_count: inserted("sample_count"),
+ market_share_tokens: inserted("market_share_tokens"),
+ market_share_requests: inserted("market_share_requests"),
+ market_share_sessions: inserted("market_share_sessions"),
+ rank_by_tokens: inserted("rank_by_tokens"),
+ rank_by_requests: inserted("rank_by_requests"),
+ rank_by_sessions: inserted("rank_by_sessions"),
+ rank_by_cost: inserted("rank_by_cost"),
+ },
+ })
+ }
+
const deleteRetiredDimensions = Effect.fn("ProviderStatRepo.deleteRetiredDimensions")(function* (
rows: ProviderStatRow[],
) {
diff --git a/packages/stats/core/src/domain/stat.ts b/packages/stats/core/src/domain/stat.ts
index 356f7403a89..553c91b9a50 100644
--- a/packages/stats/core/src/domain/stat.ts
+++ b/packages/stats/core/src/domain/stat.ts
@@ -147,6 +147,18 @@ export function combineRows(left: T, right: T): T {
}
}
+export function isMissingUniqueUsersColumn(cause: unknown): boolean {
+ return errorText(cause).includes("Unknown column 'unique_users'")
+}
+
+export function omitUniqueUsers(rows: T[]) {
+ return rows.map((row) => {
+ const result = { ...row }
+ delete result.unique_users
+ return result
+ })
+}
+
export function statPeriodKey(row: StatBaseRow) {
return [row.grain, row.period_key, row.dataset, row.tier, row.client, row.source].join("\u0000")
}
@@ -242,6 +254,15 @@ export function inserted(column: string) {
return sql.raw(`values(\`${column}\`)`)
}
+function errorText(cause: unknown): string {
+ if (cause instanceof Error) return `${cause.message} ${errorText((cause as { cause?: unknown }).cause)}`
+ if (typeof cause === "object" && cause)
+ return Object.values(cause as Record)
+ .map(errorText)
+ .join(" ")
+ return String(cause)
+}
+
export function weightedAverage(
left: number | null | undefined,
leftWeight = 0,
From 4f1a9d7aef56163d8fe265f7f9cbe295cc1df95a Mon Sep 17 00:00:00 2001
From: Kit Langton
Date: Sat, 20 Jun 2026 23:04:30 +0200
Subject: [PATCH 018/112] fix(core): honor configured agent step limits
(#33142)
---
packages/core/src/session/runner/index.ts | 11 +-
packages/core/src/session/runner/llm.ts | 48 ++++----
.../src/session/runner/max-steps.ts} | 4 +-
packages/core/test/session-runner.test.ts | 110 +++++++++---------
packages/opencode/src/session/prompt.ts | 4 +-
5 files changed, 85 insertions(+), 92 deletions(-)
rename packages/{opencode/src/session/prompt/max-steps.txt => core/src/session/runner/max-steps.ts} (90%)
diff --git a/packages/core/src/session/runner/index.ts b/packages/core/src/session/runner/index.ts
index 67a110413e7..4060cc6b044 100644
--- a/packages/core/src/session/runner/index.ts
+++ b/packages/core/src/session/runner/index.ts
@@ -1,7 +1,7 @@
export * as SessionRunner from "./index"
import type { LLMError } from "@opencode-ai/llm"
-import { Context, Effect, Schema } from "effect"
+import { Context, Effect } from "effect"
import { SessionSchema } from "../schema"
import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error"
import { SessionRunnerModel } from "./model"
@@ -9,20 +9,11 @@ import type { SystemContext } from "../../system-context/index"
import type { SessionContextEpoch } from "../context-epoch"
import type { ToolOutputStore } from "../../tool-output-store"
-export class StepLimitExceededError extends Schema.TaggedErrorClass()(
- "SessionRunner.StepLimitExceededError",
- {
- sessionID: SessionSchema.ID,
- limit: Schema.Int,
- },
-) {}
-
export type RunError =
| LLMError
| SessionRunnerModel.Error
| MessageDecodeError
| ContextSnapshotDecodeError
- | StepLimitExceededError
| SystemContext.InitializationBlocked
| SessionContextEpoch.AgentReplacementBlocked
| ToolOutputStore.Error
diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts
index 02a1eb3fed2..233a4aa4d82 100644
--- a/packages/core/src/session/runner/llm.ts
+++ b/packages/core/src/session/runner/llm.ts
@@ -3,6 +3,7 @@ import {
LLMClient,
LLMError,
LLMEvent,
+ Message,
SystemPart,
isContextOverflowFailure,
type ProviderErrorEvent,
@@ -29,10 +30,11 @@ import { SessionHistory } from "../history"
import { SessionInput } from "../input"
import { SessionSchema } from "../schema"
import { SessionStore } from "../store"
-import { type RunError, Service, StepLimitExceededError } from "./index"
+import { type RunError, Service } from "./index"
import { SessionRunnerModel } from "./model"
import { createLLMEventPublisher } from "./publish-llm-event"
import { toLLMMessages } from "./to-llm-message"
+import { MAX_STEPS_PROMPT } from "./max-steps"
/**
* Runs one durable coding-agent Session until it settles.
@@ -45,7 +47,7 @@ import { toLLMMessages } from "./to-llm-message"
* - [ ] Replace local ownership with durable multi-node ownership when clustered.
* - [ ] Mark busy, retrying, idle, interrupted, or terminal-failure status durably.
* - [ ] Honor interruption and reject stale work after runtime attachment replacement.
- * - [x] Bound model steps.
+ * - [x] Honor optional agent step limits.
* - [ ] Bound provider retries and repeated identical tool calls.
*
* - Runtime context assembly
@@ -80,13 +82,10 @@ import { toLLMMessages } from "./to-llm-message"
* Durable activity recovery remains a separate future slice with an explicit retry policy.
*
* The current slice loads V2 history, translates it, resolves a model through a core service, and persists one
- * provider turn. Registry definitions are advertised, local tool calls are settled durably, and a
- * bounded explicit loop starts the next provider turn after local settlement.
+ * provider turn. Registry definitions are advertised, local tool calls are settled durably, and an
+ * explicit loop starts the next provider turn after local settlement. Configured agent step limits bound the loop.
*/
-// QUESTION: Did this exist previously, or did we add this limit? Does it make sense?
-const MAX_STEPS = 25
-
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
@@ -175,6 +174,7 @@ export const layer = Layer.effect(
const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* (
sessionID: SessionSchema.ID,
promotion: SessionInput.Delivery | undefined,
+ step: number,
recoverOverflow?: typeof compaction.compactAfterOverflow,
) {
const session = yield* getSession(sessionID)
@@ -214,7 +214,8 @@ export const layer = Layer.effect(
const model = yield* models.resolve(session)
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
const context = entries.map((entry) => entry.message)
- const toolMaterialization = yield* tools.materialize(agent.info?.permissions)
+ const isLastStep = agent.info?.steps !== undefined && step >= agent.info.steps
+ const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agent.info?.permissions)
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
const request = LLM.request({
model,
@@ -222,8 +223,9 @@ export const layer = Layer.effect(
system: [agent.info?.system, system.baseline]
.filter((part): part is string => part !== undefined && part.length > 0)
.map(SystemPart.make),
- messages: toLLMMessages(context, model),
- tools: toolMaterialization.definitions,
+ messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])],
+ tools: toolMaterialization?.definitions ?? [],
+ toolChoice: isLastStep ? "none" : undefined,
})
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
return yield* Effect.die(rebuildPreparedTurn())
@@ -254,6 +256,10 @@ export const layer = Layer.effect(
}
yield* publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return
+ if (!toolMaterialization) {
+ yield* withPublication(publisher.failUnsettledTools("Tools are disabled after the maximum agent steps"))
+ return
+ }
needsContinuation = true
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
yield* Effect.uninterruptibleMask((restore) =>
@@ -340,31 +346,32 @@ export const layer = Layer.effect(
type RunTurn = (
sessionID: SessionSchema.ID,
promotion: SessionInput.Delivery | undefined,
+ step: number,
) => Effect.Effect
- const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion) {
- return yield* runTurnAttempt(sessionID, promotion).pipe(
+ const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) {
+ return yield* runTurnAttempt(sessionID, promotion, step).pipe(
Effect.catchDefect(
Effect.fnUntraced(function* (defect) {
if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect)
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow")
yield* Effect.yieldNow
- return yield* runAfterOverflowCompaction(sessionID, defect.transition.promotion)
+ return yield* runAfterOverflowCompaction(sessionID, defect.transition.promotion, step)
}),
),
)
})
- const runTurn: RunTurn = Effect.fnUntraced(function* (sessionID, promotion) {
- return yield* runTurnAttempt(sessionID, promotion, compaction.compactAfterOverflow).pipe(
+ const runTurn: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) {
+ return yield* runTurnAttempt(sessionID, promotion, step, compaction.compactAfterOverflow).pipe(
Effect.catchDefect(
Effect.fnUntraced(function* (defect) {
if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect)
yield* Effect.yieldNow
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
- return yield* runAfterOverflowCompaction(sessionID, undefined)
- return yield* runTurn(sessionID, defect.transition.promotion)
+ return yield* runAfterOverflowCompaction(sessionID, undefined, step)
+ return yield* runTurn(sessionID, defect.transition.promotion, step)
}),
),
)
@@ -382,14 +389,11 @@ export const layer = Layer.effect(
let openActivity = input.force === true || hasSteer || hasQueue
while (openActivity) {
let needsContinuation = true
- for (let step = 0; step < MAX_STEPS; step++) {
- needsContinuation = yield* runTurn(input.sessionID, promotion)
+ for (let step = 1; needsContinuation; step++) {
+ needsContinuation = yield* runTurn(input.sessionID, promotion, step)
promotion = "steer"
if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
- if (!needsContinuation) break
}
- if (needsContinuation)
- return yield* new StepLimitExceededError({ sessionID: input.sessionID, limit: MAX_STEPS })
openActivity = yield* SessionInput.hasPending(db, input.sessionID, "queue")
promotion = openActivity ? "queue" : undefined
}
diff --git a/packages/opencode/src/session/prompt/max-steps.txt b/packages/core/src/session/runner/max-steps.ts
similarity index 90%
rename from packages/opencode/src/session/prompt/max-steps.txt
rename to packages/core/src/session/runner/max-steps.ts
index 3aefa73779c..040584ab173 100644
--- a/packages/opencode/src/session/prompt/max-steps.txt
+++ b/packages/core/src/session/runner/max-steps.ts
@@ -1,4 +1,4 @@
-CRITICAL - MAXIMUM STEPS REACHED
+export const MAX_STEPS_PROMPT = `CRITICAL - MAXIMUM STEPS REACHED
The maximum number of steps allowed for this task has been reached. Tools are disabled until next user input. Respond with text only.
@@ -13,4 +13,4 @@ Response must include:
- List of any remaining tasks that were not completed
- Recommendations for what should be done next
-Any attempt to use tools is a critical violation. Respond with text ONLY.
\ No newline at end of file
+Any attempt to use tools is a critical violation. Respond with text ONLY.`
diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts
index af17de17594..c3089da0dba 100644
--- a/packages/core/test/session-runner.test.ts
+++ b/packages/core/test/session-runner.test.ts
@@ -1461,6 +1461,7 @@ describe("SessionRunnerLLM", () => {
})
requests.length = 0
+ executions.length = 0
responses = [
fragmentFixture("text", "text-summary-2", ["## Goal\n- Preserve the updated task"]).completeEvents,
fragmentFixture("text", "text-final-2", ["Continued again"]).completeEvents,
@@ -3177,7 +3178,7 @@ describe("SessionRunnerLLM", () => {
}),
)
- it.effect("fails after the bounded number of local tool continuation steps", () =>
+ it.effect("continues past 25 local tool steps when the agent has no step limit", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
@@ -3188,62 +3189,10 @@ describe("SessionRunnerLLM", () => {
executions.length = 0
streamGate = undefined
streamStarted = undefined
- responses = Array.from({ length: 25 }, (_, index) => [
- LLMEvent.stepStart({ index: 0 }),
- LLMEvent.toolCall({ id: `call-echo-${index}`, name: "echo", input: { text: `${index}` } }),
- LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
- LLMEvent.finish({ reason: "tool-calls" }),
- ])
-
- const failure = yield* session.resume(sessionID).pipe(Effect.flip)
-
- expect(failure).toMatchObject({ _tag: "SessionRunner.StepLimitExceededError", sessionID, limit: 25 })
- expect(requests).toHaveLength(25)
- expect(executions).toHaveLength(25)
- }),
- )
-
- it.effect("does not restart a capped tool loop for a coalesced stale wake", () =>
- Effect.gen(function* () {
- yield* setup
- const session = yield* SessionV2.Service
- const coordinator = yield* SessionRunCoordinator.Service
- yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Loop forever" }), resume: false })
-
- requests.length = 0
- responses = Array.from({ length: 25 }, (_, index) => [
- LLMEvent.stepStart({ index: 0 }),
- LLMEvent.toolCall({ id: `call-capped-${index}`, name: "echo", input: { text: `${index}` } }),
- LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
- LLMEvent.finish({ reason: "tool-calls" }),
- ])
- streamGate = yield* Deferred.make()
- streamStarted = yield* Deferred.make()
-
- const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
- yield* Deferred.await(streamStarted)
- yield* coordinator.wake(sessionID)
- yield* Deferred.succeed(streamGate, undefined)
- expect(yield* Fiber.join(run).pipe(Effect.flip)).toMatchObject({ _tag: "SessionRunner.StepLimitExceededError" })
- streamGate = undefined
- streamStarted = undefined
- yield* Effect.yieldNow
-
- expect(requests).toHaveLength(25)
- }),
- )
-
- it.effect("accepts a terminal response on the final bounded provider turn", () =>
- Effect.gen(function* () {
- yield* setup
- const session = yield* SessionV2.Service
- yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Finish at the limit" }), resume: false })
-
- requests.length = 0
responses = [
- ...Array.from({ length: 24 }, (_, index) => [
+ ...Array.from({ length: 25 }, (_, index) => [
LLMEvent.stepStart({ index: 0 }),
- LLMEvent.toolCall({ id: `call-terminal-${index}`, name: "echo", input: { text: `${index}` } }),
+ LLMEvent.toolCall({ id: `call-echo-${index}`, name: "echo", input: { text: `${index}` } }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
]),
@@ -3256,7 +3205,56 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
- expect(requests).toHaveLength(25)
+ expect(requests).toHaveLength(26)
+ expect(executions).toHaveLength(25)
+ }),
+ )
+
+ it.effect("forces a text response on an agent's configured final step", () =>
+ Effect.gen(function* () {
+ yield* setup
+ const agents = yield* AgentV2.Service
+ yield* agents.update((editor) =>
+ editor.update(AgentV2.ID.make("build"), (agent) => {
+ agent.steps = 2
+ }),
+ )
+ const session = yield* SessionV2.Service
+ yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Finish at the limit" }), resume: false })
+
+ requests.length = 0
+ executions.length = 0
+ responses = [
+ [
+ LLMEvent.stepStart({ index: 0 }),
+ LLMEvent.toolCall({ id: "call-terminal", name: "echo", input: { text: "done" } }),
+ LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
+ LLMEvent.finish({ reason: "tool-calls" }),
+ ],
+ [
+ LLMEvent.stepStart({ index: 0 }),
+ LLMEvent.toolCall({ id: "call-forbidden", name: "echo", input: { text: "forbidden" } }),
+ LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
+ LLMEvent.finish({ reason: "tool-calls" }),
+ ],
+ ]
+
+ yield* session.resume(sessionID)
+
+ expect(requests).toHaveLength(2)
+ expect(requests[0]?.toolChoice).toBeUndefined()
+ expect(requests[1]?.toolChoice).toMatchObject({ type: "none" })
+ expect(requests[1]?.tools).toEqual([])
+ expect(requests[1]?.messages.at(-1)).toMatchObject({
+ role: "assistant",
+ content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }],
+ })
+ expect(executions).toEqual(["done"])
+ expect(yield* session.context(sessionID)).toMatchObject([
+ { type: "user", text: "Finish at the limit" },
+ { type: "assistant", content: [{ type: "tool", id: "call-terminal", state: { status: "completed" } }] },
+ { type: "assistant", content: [{ type: "tool", id: "call-forbidden", state: { status: "error" } }] },
+ ])
}),
)
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index b616df6e598..299a0b6b173 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -16,7 +16,7 @@ import { SessionCompaction } from "./compaction"
import { SystemPrompt } from "./system"
import { Instruction } from "./instruction"
import { Plugin } from "../plugin"
-import MAX_STEPS from "../session/prompt/max-steps.txt"
+import { MAX_STEPS_PROMPT } from "@opencode-ai/core/session/runner/max-steps"
import { ToolRegistry } from "@/tool/registry"
import { MCP } from "../mcp"
import { LSP } from "@/lsp/lsp"
@@ -1322,7 +1322,7 @@ export const layer = Layer.effect(
sessionID,
parentSessionID: session.parentID,
system,
- messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])],
+ messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS_PROMPT }] : [])],
tools,
model,
toolChoice: format.type === "json_schema" ? "required" : undefined,
From 0b7ec51d0aefe9799e3bbe4786fe7830df6214db Mon Sep 17 00:00:00 2001
From: "opencode-agent[bot]"
Date: Sat, 20 Jun 2026 21:05:57 +0000
Subject: [PATCH 019/112] chore: generate
---
packages/opencode/src/session/prompt.ts | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 299a0b6b173..dad796c998a 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -1322,7 +1322,10 @@ export const layer = Layer.effect(
sessionID,
parentSessionID: session.parentID,
system,
- messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS_PROMPT }] : [])],
+ messages: [
+ ...modelMsgs,
+ ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS_PROMPT }] : []),
+ ],
tools,
model,
toolChoice: format.type === "json_schema" ? "required" : undefined,
From 22cc758b1ace1f31d84695f6ec2b8508b4c60d54 Mon Sep 17 00:00:00 2001
From: imranshaiedi-byte
Date: Sun, 21 Jun 2026 05:59:08 +0800
Subject: [PATCH 020/112] feat(opencode): expose High/Max thinking variants for
GLM-5.2 (#32446)
Co-authored-by: Aiden Cline
---
packages/opencode/src/provider/transform.ts | 24 ++++-
.../opencode/test/provider/transform.test.ts | 96 +++++++++++++++++++
2 files changed, 119 insertions(+), 1 deletion(-)
diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts
index f78cc53e756..e459380faae 100644
--- a/packages/opencode/src/provider/transform.ts
+++ b/packages/opencode/src/provider/transform.ts
@@ -666,6 +666,9 @@ export function variants(model: Provider.Model): Record id.includes(name) || model.api.id.toLowerCase().includes(name),
+ )
if (
model.api.id.toLowerCase().includes("minimax-m3") &&
["@ai-sdk/anthropic", "@ai-sdk/openai-compatible"].includes(model.api.npm)
@@ -677,13 +680,32 @@ export function variants(model: Provider.Model): Record {
expect(result).toEqual({})
})
+ test("glm-5.2 returns native effort variants for openai-compatible providers", () => {
+ const model = createMockModel({
+ id: "zhipuai/glm-5.2",
+ providerID: "zhipuai",
+ api: {
+ id: "glm-5.2",
+ url: "https://open.bigmodel.cn/api/paas/v4",
+ npm: "@ai-sdk/openai-compatible",
+ },
+ })
+ expect(ProviderTransform.variants(model)).toEqual({
+ high: { reasoningEffort: "high" },
+ max: { reasoningEffort: "max" },
+ })
+ })
+
+ test("recognizes GLM-5.2 provider model IDs", () => {
+ for (const id of ["accounts/fireworks/models/glm-5p2", "zai-org-glm-5-2", "umans-glm-5.2"]) {
+ const model = createMockModel({
+ id: `test/${id}`,
+ api: {
+ id,
+ url: "https://api.test.com",
+ npm: "@ai-sdk/openai-compatible",
+ },
+ })
+ expect(ProviderTransform.variants(model)).toEqual({
+ high: { reasoningEffort: "high" },
+ max: { reasoningEffort: "max" },
+ })
+ }
+ })
+
+ test("recognizes GLM-5.2 from the API ID when the configured model ID is an alias", () => {
+ const model = createMockModel({
+ id: "custom/my-glm",
+ api: {
+ id: "accounts/fireworks/models/glm-5p2",
+ url: "https://api.fireworks.ai/inference/v1",
+ npm: "@ai-sdk/openai-compatible",
+ },
+ })
+ expect(ProviderTransform.variants(model)).toEqual({
+ high: { reasoningEffort: "high" },
+ max: { reasoningEffort: "max" },
+ })
+ })
+
+ test("glm-5.2 returns openrouter effort variants for openrouter", () => {
+ const model = createMockModel({
+ id: "openrouter/z-ai/glm-5.2",
+ providerID: "openrouter",
+ api: {
+ id: "z-ai/glm-5.2",
+ url: "https://openrouter.ai/api/v1",
+ npm: "@openrouter/ai-sdk-provider",
+ },
+ })
+ expect(ProviderTransform.variants(model)).toEqual({
+ high: { reasoning: { effort: "high" } },
+ xhigh: { reasoning: { effort: "xhigh" } },
+ })
+ })
+
+ test("glm-5.2 returns effort variants for anthropic-compatible providers", () => {
+ const model = createMockModel({
+ id: "zai-coding-plan/glm-5.2",
+ providerID: "zai-coding-plan",
+ api: {
+ id: "glm-5.2",
+ url: "https://api.z.ai/api/anthropic",
+ npm: "@ai-sdk/anthropic",
+ },
+ })
+ expect(ProviderTransform.variants(model)).toEqual({
+ high: { effort: "high" },
+ max: { effort: "max" },
+ })
+ })
+
+ test("glm-5.2 falls back to provider defaults for other packages", () => {
+ const model = createMockModel({
+ id: "test/glm-5.2",
+ api: {
+ id: "glm-5.2",
+ url: "https://api.test.com",
+ npm: "@ai-sdk/amazon-bedrock",
+ },
+ })
+ expect(ProviderTransform.variants(model)).toEqual({
+ low: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } },
+ medium: { reasoningConfig: { type: "enabled", maxReasoningEffort: "medium" } },
+ high: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } },
+ })
+ })
+
test("mistral models with reasoning support return variants", () => {
const model = createMockModel({
id: "mistral/mistral-small-latest",
From d99f86b28db10c9b9460cb0bf7f984c1bd5cdb7f Mon Sep 17 00:00:00 2001
From: Dax
Date: Sun, 21 Jun 2026 00:58:27 +0200
Subject: [PATCH 021/112] fix(tui): separate subagent tool rows (#33158)
---
packages/tui/src/routes/session/index.tsx | 10 +++++++++-
.../inline-tool-wrap-snapshot.test.tsx.snap | 5 ++++-
.../test/cli/tui/inline-tool-wrap-snapshot.test.tsx | 8 ++++----
3 files changed, 17 insertions(+), 6 deletions(-)
diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx
index f36ddd9daba..e3758374c87 100644
--- a/packages/tui/src/routes/session/index.tsx
+++ b/packages/tui/src/routes/session/index.tsx
@@ -1838,6 +1838,7 @@ function InlineTool(props: {
pending: string
failure?: string
spinner?: boolean
+ separate?: boolean
children: JSX.Element
part: ToolPart
onClick?: () => void
@@ -1890,6 +1891,7 @@ function InlineTool(props: {
pending={props.pending}
failure={props.failure}
spinner={props.spinner}
+ separate={props.separate}
onMouseOver={() => clickable() && setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => {
@@ -1919,6 +1921,7 @@ export function InlineToolRow(props: {
pending: string
failure?: string
spinner?: boolean
+ separate?: boolean
children: JSX.Element
onMouseOver?: () => void
onMouseOut?: () => void
@@ -1931,8 +1934,12 @@ export function InlineToolRow(props: {
onMouseOut={props.onMouseOut}
onMouseUp={props.onMouseUp}
ref={(el: BoxRenderable) => {
+ if (props.separate) alwaysSeparate.add(el)
setPreLayoutSiblingMargin(el, (previous) => {
- return previous instanceof BoxRenderable && (previous.height > 1 || alwaysSeparate.has(previous)) ? 1 : 0
+ return props.separate ||
+ (previous instanceof BoxRenderable && (previous.height > 1 || alwaysSeparate.has(previous)))
+ ? 1
+ : 0
})
}}
>
@@ -2281,6 +2288,7 @@ function Task(props: ToolProps) {
return (
Grep "Task" (2 matches)
-
+
Explore Task — Inspect active task spacing
-
+
{"General Task — Confirm completed task spacing\n↳ 1 toolcall · 501ms"}
@@ -134,7 +134,7 @@ function LoadedReadBeforeTaskFixture() {
↳ Loaded src/cli/cmd/tui/routes/session/tools.tsx
-
+
{"Explore Task — Inspect active task spacing\n↳ 1 toolcall · 501ms"}
@@ -312,7 +312,7 @@ describe("TUI inline tool wrapping", () => {
expect(await renderFrame(() => , { width: 72, height: 10 })).toMatchSnapshot()
})
- test("does not treat task rows differently from other inline rows", async () => {
+ test("separates a task row from a preceding inline detail", async () => {
expect(await renderFrame(() => , { width: 72, height: 8 })).toMatchSnapshot()
})
From 4f1ae1604ec2b57f50b5e933fd3929f1dbe4bd00 Mon Sep 17 00:00:00 2001
From: Kit Langton
Date: Sun, 21 Jun 2026 01:57:29 +0200
Subject: [PATCH 022/112] chore: upgrade Effect to beta 83 (#32340)
---
bun.lock | 32 ++++++++++++-------
package.json | 8 ++---
packages/core/src/account.ts | 8 ++---
packages/core/src/aisdk.ts | 2 +-
.../core/src/control-plane/move-session.ts | 2 +-
packages/core/src/fs-util.ts | 2 +-
packages/core/src/git.ts | 4 +--
packages/core/src/integration.ts | 2 +-
packages/core/src/npm.ts | 2 +-
packages/core/src/process.ts | 2 +-
packages/core/src/ripgrep.ts | 2 +-
packages/core/src/tool-output-store.ts | 2 +-
packages/core/src/util/effect-flock.ts | 2 +-
packages/core/test/session-runner.test.ts | 4 +--
.../effect-drizzle-sqlite/examples/basic.ts | 2 +-
packages/http-recorder/package.json | 6 ++--
packages/llm/src/schema/errors.ts | 2 +-
packages/llm/src/schema/events.ts | 2 +-
packages/opencode/src/account/schema.ts | 8 ++---
packages/opencode/src/auth/index.ts | 2 +-
.../opencode/src/control-plane/workspace.ts | 2 +-
packages/opencode/src/lsp/client.ts | 2 +-
packages/opencode/src/provider/provider.ts | 4 +--
packages/stats/core/src/athena.ts | 2 +-
packages/stats/core/src/database.ts | 4 +--
packages/stats/server/src/ingest.ts | 2 +-
26 files changed, 60 insertions(+), 52 deletions(-)
diff --git a/bun.lock b/bun.lock
index 4e4aa7a707d..a0b3d9b31bb 100644
--- a/bun.lock
+++ b/bun.lock
@@ -460,8 +460,8 @@
"name": "@opencode-ai/http-recorder",
"version": "1.17.8",
"dependencies": {
- "@effect/platform-node": "4.0.0-beta.74",
- "@effect/platform-node-shared": "4.0.0-beta.74",
+ "@effect/platform-node": "4.0.0-beta.83",
+ "@effect/platform-node-shared": "4.0.0-beta.83",
},
"devDependencies": {
"@tsconfig/node22": "catalog:",
@@ -472,7 +472,7 @@
"typescript": "catalog:",
},
"peerDependencies": {
- "effect": "4.0.0-beta.74",
+ "effect": "4.0.0-beta.83",
},
},
"packages/llm": {
@@ -935,9 +935,9 @@
},
"catalog": {
"@cloudflare/workers-types": "4.20251008.0",
- "@effect/opentelemetry": "4.0.0-beta.74",
- "@effect/platform-node": "4.0.0-beta.74",
- "@effect/sql-sqlite-bun": "4.0.0-beta.74",
+ "@effect/opentelemetry": "4.0.0-beta.83",
+ "@effect/platform-node": "4.0.0-beta.83",
+ "@effect/sql-sqlite-bun": "4.0.0-beta.83",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11",
@@ -973,7 +973,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
- "effect": "4.0.0-beta.74",
+ "effect": "4.0.0-beta.83",
"fuzzysort": "3.1.0",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
@@ -1328,13 +1328,13 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
- "@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.74", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.74" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-flpyqLPyr+THSe6ZCGRZl6hi+FqxbIXNSkslKGiRJAjbPabam9mSp7R3aC8biIMt6xE4Fd0LNfo4p2GplUkm2Q=="],
+ "@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.83", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.83" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-cPfCfp/ghu0itbX6Dqjdr4N0rbjng5ON4sUpnLHV5JJySG8zZpWmuOZLWIrfrNKT2ctYR1BYmp1aYCgkItaJLw=="],
- "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.74", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.74", "mime": "^4.1.0", "undici": "^8.2.0" }, "peerDependencies": { "effect": "^4.0.0-beta.74", "ioredis": "^5.7.0" } }, "sha512-/W16mKqxvhWINLjufzc0log1sl57exXQfwd+em398/zKCbmU3S7snXTDMN6w0ju2TtgK35qrsoGBXEochij6Sg=="],
+ "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.83", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.83", "mime": "^4.1.0", "undici": "^8.2.0" }, "peerDependencies": { "effect": "^4.0.0-beta.83", "ioredis": "^5.7.0" } }, "sha512-RmpVGu/+X/Bif3/g1Rzj8oFzTOknoVB3yHCa0b179vytPpKe+Kj9ZwKNcAnKWqHUDkbSPBq1Ca60mvOHr2/+LQ=="],
- "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.74", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-C6C2hXixNcZXLaFF2u7B/FtOsqpdY7luaPuiGFBJza0P7EnYDkwaT3kB6lv7l/qctmkADc24qOsSCWIKRbC4jg=="],
+ "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.83", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.83" } }, "sha512-+yr/+PJmKTgmJq1QOINSBPgLu7Cjc4CZcotBXnGjyDEizOmimFgTkN2B8PBJAKIKUWYWfobjXqC+58/VhhPKAw=="],
- "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.74", "", { "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-RVMRVY7NhSoAp9cAAyy4TT6dt6NNZjOpWeqticoho9HNBukxQSUcu/kjcz4Iq9eoQfXadmepu8kZqtdZULM/fg=="],
+ "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.83", "", { "peerDependencies": { "effect": "^4.0.0-beta.83" } }, "sha512-6OaxLsWffxkh9pXYUSyj/AxjVb9URY2rG9U6atjxClWy30Jx77R9Pm3Rrc7cQ63kQurePavEw1bQbzQ/SILiQQ=="],
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
@@ -3366,7 +3366,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
- "effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
+ "effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
@@ -5898,6 +5898,10 @@
"@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
+ "@standard-community/standard-json/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
+
+ "@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
+
"@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
"@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -6700,6 +6704,10 @@
"@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
+ "@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+
+ "@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+
"@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
diff --git a/package.json b/package.json
index c0dc905aae4..49507128d60 100644
--- a/package.json
+++ b/package.json
@@ -30,9 +30,9 @@
"packages/slack"
],
"catalog": {
- "@effect/opentelemetry": "4.0.0-beta.74",
- "@effect/platform-node": "4.0.0-beta.74",
- "@effect/sql-sqlite-bun": "4.0.0-beta.74",
+ "@effect/opentelemetry": "4.0.0-beta.83",
+ "@effect/platform-node": "4.0.0-beta.83",
+ "@effect/sql-sqlite-bun": "4.0.0-beta.83",
"@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6",
@@ -61,7 +61,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
- "effect": "4.0.0-beta.74",
+ "effect": "4.0.0-beta.83",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
diff --git a/packages/core/src/account.ts b/packages/core/src/account.ts
index 4de8176e4bc..d364d6f3448 100644
--- a/packages/core/src/account.ts
+++ b/packages/core/src/account.ts
@@ -35,19 +35,19 @@ export class Org extends Schema.Class("Org")({
export class AccountRepoError extends Schema.TaggedErrorClass()("AccountRepoError", {
message: Schema.String,
- cause: Schema.optional(Schema.Defect),
+ cause: Schema.optional(Schema.Defect()),
}) {}
export class AccountServiceError extends Schema.TaggedErrorClass()("AccountServiceError", {
message: Schema.String,
- cause: Schema.optional(Schema.Defect),
+ cause: Schema.optional(Schema.Defect()),
}) {}
export class AccountTransportError extends Schema.TaggedErrorClass()("AccountTransportError", {
method: Schema.String,
url: Schema.String,
description: Schema.optional(Schema.String),
- cause: Schema.optional(Schema.Defect),
+ cause: Schema.optional(Schema.Defect()),
}) {
static fromHttpClientError(error: HttpClientError.TransportError): AccountTransportError {
return new AccountTransportError({
@@ -94,7 +94,7 @@ export class PollExpired extends Schema.TaggedClass()("PollExpired"
export class PollDenied extends Schema.TaggedClass()("PollDenied", {}) {}
export class PollError extends Schema.TaggedClass()("PollError", {
- cause: Schema.Defect,
+ cause: Schema.Defect(),
}) {}
export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError])
diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts
index 9965ff930dd..769941fd276 100644
--- a/packages/core/src/aisdk.ts
+++ b/packages/core/src/aisdk.ts
@@ -109,7 +109,7 @@ function prepareOptions(model: ModelV2.Info, pkg: string) {
export class InitError extends Schema.TaggedErrorClass()("AISDK.InitError", {
providerID: ProviderV2.ID,
- cause: Schema.Defect,
+ cause: Schema.Defect(),
}) {}
function initError(providerID: ProviderV2.ID) {
diff --git a/packages/core/src/control-plane/move-session.ts b/packages/core/src/control-plane/move-session.ts
index 0239eecada2..fa2a3cb5493 100644
--- a/packages/core/src/control-plane/move-session.ts
+++ b/packages/core/src/control-plane/move-session.ts
@@ -48,7 +48,7 @@ export class ResetSourceChangesError extends Schema.TaggedErrorClass()("FileSystemError", {
method: Schema.String,
- cause: Schema.optional(Schema.Defect),
+ cause: Schema.optional(Schema.Defect()),
}) {}
export type Error = PlatformError | FileSystemError
diff --git a/packages/core/src/git.ts b/packages/core/src/git.ts
index 0041c3353f4..b7ef916330e 100644
--- a/packages/core/src/git.ts
+++ b/packages/core/src/git.ts
@@ -32,14 +32,14 @@ export class WorktreeError extends Schema.TaggedErrorClass()("Git
message: Schema.String,
directory: Schema.optional(AbsolutePath),
forceRequired: Schema.optional(Schema.Boolean),
- cause: Schema.optional(Schema.Defect),
+ cause: Schema.optional(Schema.Defect()),
}) {}
export class PatchError extends Schema.TaggedErrorClass()("Git.PatchError", {
operation: Schema.Literals(["capture", "apply", "reset"]),
directory: AbsolutePath,
message: Schema.String,
- cause: Schema.optional(Schema.Defect),
+ cause: Schema.optional(Schema.Defect()),
}) {}
export interface Interface {
diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts
index ca626111a01..90995b1987c 100644
--- a/packages/core/src/integration.ts
+++ b/packages/core/src/integration.ts
@@ -154,7 +154,7 @@ export class CodeRequiredError extends Schema.TaggedErrorClass()("Integration.Authorization", {
- cause: Schema.Defect,
+ cause: Schema.Defect(),
}) {}
export type Error = CodeRequiredError | AuthorizationError
diff --git a/packages/core/src/npm.ts b/packages/core/src/npm.ts
index f3398e83911..48ad74c1807 100644
--- a/packages/core/src/npm.ts
+++ b/packages/core/src/npm.ts
@@ -15,7 +15,7 @@ import { NpmConfig } from "./npm-config"
export class InstallFailedError extends Schema.TaggedErrorClass()("NpmInstallFailedError", {
add: Schema.Array(Schema.String).pipe(Schema.optional),
dir: Schema.String,
- cause: Schema.optional(Schema.Defect),
+ cause: Schema.optional(Schema.Defect()),
}) {}
export interface EntryPoint {
diff --git a/packages/core/src/process.ts b/packages/core/src/process.ts
index 44418d74c1b..8367ad68e15 100644
--- a/packages/core/src/process.ts
+++ b/packages/core/src/process.ts
@@ -9,7 +9,7 @@ export class AppProcessError extends Schema.TaggedErrorClass()(
command: Schema.String,
exitCode: Schema.optional(Schema.Number),
stderr: Schema.optional(Schema.String),
- cause: Schema.optional(Schema.Defect),
+ cause: Schema.optional(Schema.Defect()),
}) {}
export interface RunOptions {
diff --git a/packages/core/src/ripgrep.ts b/packages/core/src/ripgrep.ts
index 5a8a94f337d..b68078b3fef 100644
--- a/packages/core/src/ripgrep.ts
+++ b/packages/core/src/ripgrep.ts
@@ -42,7 +42,7 @@ type RawMatchData = (typeof RawMatch.Type)["data"]
export class Error extends Schema.TaggedErrorClass()("Ripgrep.Error", {
message: Schema.String,
- cause: Schema.optional(Schema.Defect),
+ cause: Schema.optional(Schema.Defect()),
}) {}
export class InvalidPatternError extends Schema.TaggedErrorClass()("Ripgrep.InvalidPatternError", {
diff --git a/packages/core/src/tool-output-store.ts b/packages/core/src/tool-output-store.ts
index 2d15ee8d0dc..685cf5c582b 100644
--- a/packages/core/src/tool-output-store.ts
+++ b/packages/core/src/tool-output-store.ts
@@ -28,7 +28,7 @@ export interface BoundResult {
export class StorageError extends Schema.TaggedErrorClass()("ToolOutputStore.StorageError", {
operation: Schema.Literals(["encode", "write"]),
- cause: Schema.Defect,
+ cause: Schema.Defect(),
}) {}
export type Error = StorageError
diff --git a/packages/core/src/util/effect-flock.ts b/packages/core/src/util/effect-flock.ts
index 2ba5ef0d759..fa864e92525 100644
--- a/packages/core/src/util/effect-flock.ts
+++ b/packages/core/src/util/effect-flock.ts
@@ -24,7 +24,7 @@ export namespace EffectFlock {
class ReleaseError extends Schema.TaggedErrorClass()("ReleaseError", {
detail: Schema.String,
- cause: Schema.optional(Schema.Defect),
+ cause: Schema.optional(Schema.Defect()),
}) {
override get message() {
return this.detail
diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts
index c3089da0dba..6ff969fb62a 100644
--- a/packages/core/test/session-runner.test.ts
+++ b/packages/core/test/session-runner.test.ts
@@ -704,7 +704,7 @@ describe("SessionRunnerLLM", () => {
yield* events.publish(SessionEvent.Moved, {
sessionID,
timestamp: DateTime.makeUnsafe(1),
- location: { directory: AbsolutePath.make("/moved") },
+ location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
})
expect(
yield* db
@@ -762,7 +762,7 @@ describe("SessionRunnerLLM", () => {
.publish(SessionEvent.Moved, {
sessionID,
timestamp: DateTime.makeUnsafe(1),
- location: { directory: AbsolutePath.make("/moved") },
+ location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
})
.pipe(Effect.asVoid)
})
diff --git a/packages/effect-drizzle-sqlite/examples/basic.ts b/packages/effect-drizzle-sqlite/examples/basic.ts
index 675aabcb857..80397cd6e6c 100644
--- a/packages/effect-drizzle-sqlite/examples/basic.ts
+++ b/packages/effect-drizzle-sqlite/examples/basic.ts
@@ -25,7 +25,7 @@ class Database extends Context.Service()("@opencode/exa
class UserStoreError extends Schema.TaggedErrorClass()("UserStoreError", {
message: Schema.String,
- cause: Schema.optional(Schema.Defect),
+ cause: Schema.optional(Schema.Defect()),
}) {}
const mapStoreError = (message: string) => (cause: unknown) => new UserStoreError({ message, cause })
diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json
index 29bda69edf9..c9fd3434ca0 100644
--- a/packages/http-recorder/package.json
+++ b/packages/http-recorder/package.json
@@ -51,10 +51,10 @@
"typescript": "catalog:"
},
"dependencies": {
- "@effect/platform-node": "4.0.0-beta.74",
- "@effect/platform-node-shared": "4.0.0-beta.74"
+ "@effect/platform-node": "4.0.0-beta.83",
+ "@effect/platform-node-shared": "4.0.0-beta.83"
},
"peerDependencies": {
- "effect": "4.0.0-beta.74"
+ "effect": "4.0.0-beta.83"
}
}
diff --git a/packages/llm/src/schema/errors.ts b/packages/llm/src/schema/errors.ts
index 35546ca30b4..072e4e83892 100644
--- a/packages/llm/src/schema/errors.ts
+++ b/packages/llm/src/schema/errors.ts
@@ -202,6 +202,6 @@ export class LLMError extends Schema.TaggedErrorClass()("LLM.Error", {
*/
export class ToolFailure extends Schema.TaggedErrorClass()("LLM.ToolFailure", {
message: Schema.String,
- error: Schema.optional(Schema.Defect),
+ error: Schema.optional(Schema.Defect()),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
diff --git a/packages/llm/src/schema/events.ts b/packages/llm/src/schema/events.ts
index a685f07d5e1..3e46013521e 100644
--- a/packages/llm/src/schema/events.ts
+++ b/packages/llm/src/schema/events.ts
@@ -175,7 +175,7 @@ export const ToolError = Schema.Struct({
id: ToolCallID,
name: Schema.String,
message: Schema.String,
- error: Schema.optional(Schema.Defect),
+ error: Schema.optional(Schema.Defect()),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolError" })
export type ToolError = Schema.Schema.Type
diff --git a/packages/opencode/src/account/schema.ts b/packages/opencode/src/account/schema.ts
index 222296ff1bc..8c008435ab5 100644
--- a/packages/opencode/src/account/schema.ts
+++ b/packages/opencode/src/account/schema.ts
@@ -33,19 +33,19 @@ export class Org extends Schema.Class("Org")({
export class AccountRepoError extends Schema.TaggedErrorClass()("AccountRepoError", {
message: Schema.String,
- cause: Schema.optional(Schema.Defect),
+ cause: Schema.optional(Schema.Defect()),
}) {}
export class AccountServiceError extends Schema.TaggedErrorClass()("AccountServiceError", {
message: Schema.String,
- cause: Schema.optional(Schema.Defect),
+ cause: Schema.optional(Schema.Defect()),
}) {}
export class AccountTransportError extends Schema.TaggedErrorClass()("AccountTransportError", {
method: Schema.String,
url: Schema.String,
description: Schema.optional(Schema.String),
- cause: Schema.optional(Schema.Defect),
+ cause: Schema.optional(Schema.Defect()),
}) {
static fromHttpClientError(error: HttpClientError.TransportError): AccountTransportError {
return new AccountTransportError({
@@ -92,7 +92,7 @@ export class PollExpired extends Schema.TaggedClass()("PollExpired"
export class PollDenied extends Schema.TaggedClass()("PollDenied", {}) {}
export class PollError extends Schema.TaggedClass()("PollError", {
- cause: Schema.Defect,
+ cause: Schema.Defect(),
}) {}
export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError])
diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts
index 5c18bc3caad..20f93798242 100644
--- a/packages/opencode/src/auth/index.ts
+++ b/packages/opencode/src/auth/index.ts
@@ -37,7 +37,7 @@ export type Info = Schema.Schema.Type
export class AuthError extends Schema.TaggedErrorClass()("AuthError", {
message: Schema.String,
- cause: Schema.optional(Schema.Defect),
+ cause: Schema.optional(Schema.Defect()),
}) {}
export interface Interface {
diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts
index 76aab6ef61a..0fdd6f0c7dc 100644
--- a/packages/opencode/src/control-plane/workspace.ts
+++ b/packages/opencode/src/control-plane/workspace.ts
@@ -131,7 +131,7 @@ export class SyncTimeoutError extends Schema.TaggedErrorClass(
export class SyncAbortedError extends Schema.TaggedErrorClass()("WorkspaceSyncAbortedError", {
message: Schema.String,
- cause: Schema.optional(Schema.Defect),
+ cause: Schema.optional(Schema.Defect()),
}) {}
type CreateError = Auth.AuthError
diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts
index 0949ec1be08..08d8a53d9be 100644
--- a/packages/opencode/src/lsp/client.ts
+++ b/packages/opencode/src/lsp/client.ts
@@ -28,7 +28,7 @@ export type Diagnostic = VSCodeDiagnostic
export class InitializeError extends Schema.TaggedErrorClass()("LSPInitializeError", {
serverID: Schema.String,
- cause: Schema.optional(Schema.Defect),
+ cause: Schema.optional(Schema.Defect()),
}) {}
type DocumentDiagnosticReport = {
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
index 63ad8d0d7f6..d7c85f2ee6a 100644
--- a/packages/opencode/src/provider/provider.ts
+++ b/packages/opencode/src/provider/provider.ts
@@ -1076,7 +1076,7 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass()("ProviderInitError", {
providerID: ProviderV2.ID,
- cause: Schema.optional(Schema.Defect),
+ cause: Schema.optional(Schema.Defect()),
}) {
static isInstance(input: unknown): input is InitError {
return input instanceof InitError
diff --git a/packages/stats/core/src/athena.ts b/packages/stats/core/src/athena.ts
index a2be44ebb76..54037002f57 100644
--- a/packages/stats/core/src/athena.ts
+++ b/packages/stats/core/src/athena.ts
@@ -17,7 +17,7 @@ export type AthenaData = Record
export class AthenaQueryError extends Schema.TaggedErrorClass()("AthenaQueryError", {
message: Schema.String,
queryExecutionId: Schema.optional(Schema.String),
- cause: Schema.optional(Schema.Defect),
+ cause: Schema.optional(Schema.Defect()),
}) {}
export class AthenaQueryTimeoutError extends Schema.TaggedErrorClass()(
diff --git a/packages/stats/core/src/database.ts b/packages/stats/core/src/database.ts
index d265f82bf1e..9edb717bc71 100644
--- a/packages/stats/core/src/database.ts
+++ b/packages/stats/core/src/database.ts
@@ -45,14 +45,14 @@ export class DrizzleClient extends Context.Service()("@o
}
export class DatabaseError extends Schema.TaggedErrorClass()("DatabaseError", {
- cause: Schema.Defect,
+ cause: Schema.Defect(),
}) {}
export const catchDbError = Effect.mapError((cause) => DatabaseError.make({ cause }))
export class MigrationError extends Schema.TaggedErrorClass()("MigrationError", {
message: Schema.String,
- cause: Schema.optional(Schema.Defect),
+ cause: Schema.optional(Schema.Defect()),
}) {}
export const migrate = Effect.fn("Database.migrate")(function* () {
diff --git a/packages/stats/server/src/ingest.ts b/packages/stats/server/src/ingest.ts
index 62972e30fab..763742d9c99 100644
--- a/packages/stats/server/src/ingest.ts
+++ b/packages/stats/server/src/ingest.ts
@@ -15,7 +15,7 @@ type FirehoseRecord = { Data: Uint8Array }
export class IngestError extends Schema.TaggedErrorClass()("IngestError", {
message: Schema.String,
failed: Schema.Number,
- cause: Schema.optional(Schema.Defect),
+ cause: Schema.optional(Schema.Defect()),
}) {}
export declare namespace Ingest {
From e84d94d99804c647c418c274ac9120ef27a5b46d Mon Sep 17 00:00:00 2001
From: opencode
Date: Sun, 21 Jun 2026 00:03:08 +0000
Subject: [PATCH 023/112] sync release versions for v1.17.9
---
bun.lock | 52 ++++++++++-----------
packages/app/package.json | 2 +-
packages/cli/package.json | 2 +-
packages/console/app/package.json | 2 +-
packages/console/core/package.json | 2 +-
packages/console/function/package.json | 2 +-
packages/console/mail/package.json | 2 +-
packages/console/support/package.json | 2 +-
packages/core/package.json | 2 +-
packages/desktop/package.json | 2 +-
packages/effect-drizzle-sqlite/package.json | 2 +-
packages/effect-sqlite-node/package.json | 2 +-
packages/enterprise/package.json | 2 +-
packages/function/package.json | 2 +-
packages/http-recorder/package.json | 2 +-
packages/llm/package.json | 2 +-
packages/opencode/package.json | 2 +-
packages/plugin/package.json | 2 +-
packages/sdk/js/package.json | 2 +-
packages/server/package.json | 2 +-
packages/slack/package.json | 2 +-
packages/stats/app/package.json | 2 +-
packages/stats/core/package.json | 2 +-
packages/stats/server/package.json | 2 +-
packages/tui/package.json | 2 +-
packages/ui/package.json | 2 +-
packages/web/package.json | 2 +-
sdks/vscode/package.json | 2 +-
28 files changed, 53 insertions(+), 53 deletions(-)
diff --git a/bun.lock b/bun.lock
index a0b3d9b31bb..e38e531d994 100644
--- a/bun.lock
+++ b/bun.lock
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
- "version": "1.17.8",
+ "version": "1.17.9",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -86,7 +86,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
- "version": "1.17.8",
+ "version": "1.17.9",
"bin": {
"lildax": "./bin/lildax.cjs",
},
@@ -111,7 +111,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
- "version": "1.17.8",
+ "version": "1.17.9",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -147,7 +147,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
- "version": "1.17.8",
+ "version": "1.17.9",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -174,7 +174,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
- "version": "1.17.8",
+ "version": "1.17.9",
"dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
@@ -196,7 +196,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
- "version": "1.17.8",
+ "version": "1.17.9",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -220,7 +220,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
- "version": "1.17.8",
+ "version": "1.17.9",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -240,7 +240,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
- "version": "1.17.8",
+ "version": "1.17.9",
"bin": {
"opencode": "./bin/opencode",
},
@@ -331,7 +331,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
- "version": "1.17.8",
+ "version": "1.17.9",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:",
@@ -385,7 +385,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
- "version": "1.17.8",
+ "version": "1.17.9",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -399,7 +399,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
- "version": "1.17.8",
+ "version": "1.17.9",
"dependencies": {
"effect": "catalog:",
},
@@ -411,7 +411,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
- "version": "1.17.8",
+ "version": "1.17.9",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -442,7 +442,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
- "version": "1.17.8",
+ "version": "1.17.9",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -458,7 +458,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
- "version": "1.17.8",
+ "version": "1.17.9",
"dependencies": {
"@effect/platform-node": "4.0.0-beta.83",
"@effect/platform-node-shared": "4.0.0-beta.83",
@@ -477,7 +477,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
- "version": "1.17.8",
+ "version": "1.17.9",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -495,7 +495,7 @@
},
"packages/opencode": {
"name": "opencode",
- "version": "1.17.8",
+ "version": "1.17.9",
"bin": {
"opencode": "./bin/opencode",
},
@@ -623,7 +623,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
- "version": "1.17.8",
+ "version": "1.17.9",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -661,7 +661,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
- "version": "1.17.8",
+ "version": "1.17.9",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -676,7 +676,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
- "version": "1.17.8",
+ "version": "1.17.9",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"drizzle-orm": "catalog:",
@@ -690,7 +690,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
- "version": "1.17.8",
+ "version": "1.17.9",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -703,7 +703,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
- "version": "1.17.8",
+ "version": "1.17.9",
"dependencies": {
"@ibm/plex": "6.4.1",
"@opencode-ai/stats-core": "workspace:*",
@@ -736,7 +736,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
- "version": "1.17.8",
+ "version": "1.17.9",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -755,7 +755,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
- "version": "1.17.8",
+ "version": "1.17.9",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -795,7 +795,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
- "version": "1.17.8",
+ "version": "1.17.9",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -822,7 +822,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
- "version": "1.17.8",
+ "version": "1.17.9",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -871,7 +871,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
- "version": "1.17.8",
+ "version": "1.17.9",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
diff --git a/packages/app/package.json b/packages/app/package.json
index b572e7308b7..e4906b9ea4b 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
- "version": "1.17.8",
+ "version": "1.17.9",
"description": "",
"type": "module",
"exports": {
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 4d2aba06697..96283c8269c 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
- "version": "1.17.8",
+ "version": "1.17.9",
"type": "module",
"license": "MIT",
"bin": {
diff --git a/packages/console/app/package.json b/packages/console/app/package.json
index e7840b4782c..e706bd6b062 100644
--- a/packages/console/app/package.json
+++ b/packages/console/app/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
- "version": "1.17.8",
+ "version": "1.17.9",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/console/core/package.json b/packages/console/core/package.json
index b5e95fa946c..fa401b65c22 100644
--- a/packages/console/core/package.json
+++ b/packages/console/core/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
- "version": "1.17.8",
+ "version": "1.17.9",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/console/function/package.json b/packages/console/function/package.json
index c215d9da491..7db84f475f0 100644
--- a/packages/console/function/package.json
+++ b/packages/console/function/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
- "version": "1.17.8",
+ "version": "1.17.9",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json
index 6adb5489ddc..deb27b5e2c4 100644
--- a/packages/console/mail/package.json
+++ b/packages/console/mail/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
- "version": "1.17.8",
+ "version": "1.17.9",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
diff --git a/packages/console/support/package.json b/packages/console/support/package.json
index 2b6d53eac86..e7ca9810a3c 100644
--- a/packages/console/support/package.json
+++ b/packages/console/support/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-support",
- "version": "1.17.8",
+ "version": "1.17.9",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/core/package.json b/packages/core/package.json
index 26449b5165b..7e12e5bac1f 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
- "version": "1.17.8",
+ "version": "1.17.9",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index 7933b3982c9..d3bd09bc0f5 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
- "version": "1.17.8",
+ "version": "1.17.9",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json
index 033ccb4f0de..8fe9c58f5e2 100644
--- a/packages/effect-drizzle-sqlite/package.json
+++ b/packages/effect-drizzle-sqlite/package.json
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
- "version": "1.17.8",
+ "version": "1.17.9",
"name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module",
"license": "MIT",
diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json
index 52860ade59b..3f8f18a9aaf 100644
--- a/packages/effect-sqlite-node/package.json
+++ b/packages/effect-sqlite-node/package.json
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
- "version": "1.17.8",
+ "version": "1.17.9",
"name": "@opencode-ai/effect-sqlite-node",
"type": "module",
"license": "MIT",
diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json
index cf775b9015c..69534b07f73 100644
--- a/packages/enterprise/package.json
+++ b/packages/enterprise/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
- "version": "1.17.8",
+ "version": "1.17.9",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/function/package.json b/packages/function/package.json
index 5a9259f61b7..c4d35031cb1 100644
--- a/packages/function/package.json
+++ b/packages/function/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
- "version": "1.17.8",
+ "version": "1.17.9",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json
index c9fd3434ca0..7c70a18f209 100644
--- a/packages/http-recorder/package.json
+++ b/packages/http-recorder/package.json
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
- "version": "1.17.8",
+ "version": "1.17.9",
"name": "@opencode-ai/http-recorder",
"description": "Record and replay Effect HTTP client traffic with deterministic cassettes",
"type": "module",
diff --git a/packages/llm/package.json b/packages/llm/package.json
index 205a27c3225..e1a691c0eed 100644
--- a/packages/llm/package.json
+++ b/packages/llm/package.json
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
- "version": "1.17.8",
+ "version": "1.17.9",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index fe6e8a8ff1b..139217e348a 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
- "version": "1.17.8",
+ "version": "1.17.9",
"name": "opencode",
"type": "module",
"license": "MIT",
diff --git a/packages/plugin/package.json b/packages/plugin/package.json
index b3c4f40137c..d046e4e9f6d 100644
--- a/packages/plugin/package.json
+++ b/packages/plugin/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
- "version": "1.17.8",
+ "version": "1.17.9",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json
index a0dc88f4a3b..37b6889078b 100644
--- a/packages/sdk/js/package.json
+++ b/packages/sdk/js/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
- "version": "1.17.8",
+ "version": "1.17.9",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/server/package.json b/packages/server/package.json
index 9bc7f446777..18cd2ab1f2a 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/server",
- "version": "1.17.8",
+ "version": "1.17.9",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/slack/package.json b/packages/slack/package.json
index d04cac52434..c4ba07b2391 100644
--- a/packages/slack/package.json
+++ b/packages/slack/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
- "version": "1.17.8",
+ "version": "1.17.9",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json
index 8317c5bc5e9..d7c6660d702 100644
--- a/packages/stats/app/package.json
+++ b/packages/stats/app/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-app",
- "version": "1.17.8",
+ "version": "1.17.9",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json
index 2dcfd54655b..c54cb7434cf 100644
--- a/packages/stats/core/package.json
+++ b/packages/stats/core/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-core",
- "version": "1.17.8",
+ "version": "1.17.9",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/stats/server/package.json b/packages/stats/server/package.json
index a53b24236b4..c10273a3a0d 100644
--- a/packages/stats/server/package.json
+++ b/packages/stats/server/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-server",
- "version": "1.17.8",
+ "version": "1.17.9",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/tui/package.json b/packages/tui/package.json
index be0ddbfe416..aa6b6c70f04 100644
--- a/packages/tui/package.json
+++ b/packages/tui/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/tui",
- "version": "1.17.8",
+ "version": "1.17.9",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/ui/package.json b/packages/ui/package.json
index a2485f475a6..489396ad499 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
- "version": "1.17.8",
+ "version": "1.17.9",
"type": "module",
"license": "MIT",
"exports": {
diff --git a/packages/web/package.json b/packages/web/package.json
index 756726d1500..3d77162aa1e 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
- "version": "1.17.8",
+ "version": "1.17.9",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json
index 37c940a312c..ead6f458879 100644
--- a/sdks/vscode/package.json
+++ b/sdks/vscode/package.json
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
- "version": "1.17.8",
+ "version": "1.17.9",
"publisher": "sst-dev",
"repository": {
"type": "git",
From 5606d2bab9744e5ec7992fffe424569fadf37ae7 Mon Sep 17 00:00:00 2001
From: "opencode-agent[bot]"
Date: Sun, 21 Jun 2026 00:19:31 +0000
Subject: [PATCH 024/112] chore: update nix node_modules hashes
---
nix/hashes.json | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/nix/hashes.json b/nix/hashes.json
index b7231c92abc..86502338019 100644
--- a/nix/hashes.json
+++ b/nix/hashes.json
@@ -1,8 +1,8 @@
{
"nodeModules": {
- "x86_64-linux": "sha256-LOxTad/iCquvJyonFOcz6/rDTPNDmwyBnykhWZJ5GC4=",
- "aarch64-linux": "sha256-iO+0vYhp+2x6ACmh5lQJ/2Ac4uZTqRZE/KhG3u0o6D8=",
- "aarch64-darwin": "sha256-tpBydRbrJ+4QxmkGUt/BhME8q6ysCW/CXrsNshYgqDU=",
- "x86_64-darwin": "sha256-QQcI6SK7WJ7dSkX6xZuSQPoUdwfoCaimVgoHCnrO0wY="
+ "x86_64-linux": "sha256-g0tDvRf7MErZ1PEeUazEYi492ZHiRT8kYv3bPdkss/I=",
+ "aarch64-linux": "sha256-6sKgf3ftbIqlPxlFkoPzoWPsJp3IwXD+H3Y6g874xmk=",
+ "aarch64-darwin": "sha256-Se/Nls/KlkuK2ysDQ9DeAzSaX3NsL2iDdf/dsv2GIXc=",
+ "x86_64-darwin": "sha256-V9MCkqnvQ1nkD2PaaTfNFKkBZGymj6KxrSAK6+DTF8Y="
}
}
From d59619fffd517357c582b4b376feb589bdf945ab Mon Sep 17 00:00:00 2001
From: James Long
Date: Sun, 21 Jun 2026 03:43:03 +0200
Subject: [PATCH 025/112] test(opencode): simplify git layer wiring (#33156)
---
packages/opencode/test/git/git.test.ts | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/packages/opencode/test/git/git.test.ts b/packages/opencode/test/git/git.test.ts
index e80b8fa9065..c786513c636 100644
--- a/packages/opencode/test/git/git.test.ts
+++ b/packages/opencode/test/git/git.test.ts
@@ -3,12 +3,13 @@ import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect } from "effect"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Git } from "../../src/git"
import { tmpdir } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt"
-const it = testEffect(Git.defaultLayer)
+const it = testEffect(LayerNode.buildLayer(Git.node))
const scopedTmpdir = (options?: Parameters[0]) =>
Effect.acquireRelease(
From 468f425e76b324890a6ca05d3bc0fe59b0058cb3 Mon Sep 17 00:00:00 2001
From: James Long
Date: Sun, 21 Jun 2026 03:44:02 +0200
Subject: [PATCH 026/112] test(opencode): simplify session retry layer wiring
(#33155)
---
packages/opencode/test/session/retry.test.ts | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts
index f5edf1af24b..e53a6c1f18e 100644
--- a/packages/opencode/test/session/retry.test.ts
+++ b/packages/opencode/test/session/retry.test.ts
@@ -3,7 +3,8 @@ import { SessionV1 } from "@opencode-ai/core/v1/session"
import type { NamedError } from "@opencode-ai/core/util/error"
import { APICallError } from "ai"
import { setTimeout as sleep } from "node:timers/promises"
-import { Effect, Layer, Schedule, Schema } from "effect"
+import { Effect, Schedule, Schema } from "effect"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { SessionRetry } from "../../src/session/retry"
import { MessageV2 } from "../../src/session/message-v2"
@@ -15,7 +16,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
const providerID = ProviderV2.ID.make("test")
const retryProvider = "test"
-const it = testEffect(Layer.mergeAll(SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer))
+const it = testEffect(LayerNode.buildLayer(LayerNode.group([SessionStatus.node, CrossSpawnSpawner.node])))
function apiError(headers?: Record): SessionV1.APIError {
return Schema.decodeUnknownSync(SessionV1.APIError.Schema)(
From d3bbfff826c58708bb55ef11737943436305da7b Mon Sep 17 00:00:00 2001
From: James Long
Date: Sun, 21 Jun 2026 03:44:37 +0200
Subject: [PATCH 027/112] test(opencode): simplify message pagination layer
wiring (#33157)
---
packages/opencode/src/session/message-v2.ts | 3 +++
packages/opencode/test/session/messages-pagination.test.ts | 7 ++++---
2 files changed, 7 insertions(+), 3 deletions(-)
diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts
index 1590e089037..813fe49f325 100644
--- a/packages/opencode/src/session/message-v2.ts
+++ b/packages/opencode/src/session/message-v2.ts
@@ -22,6 +22,7 @@ import {
import { NamedError } from "@opencode-ai/core/util/error"
import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai"
import { Database } from "@opencode-ai/core/database/database"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { NotFoundError } from "@/storage/storage"
import { and } from "drizzle-orm"
import { desc } from "drizzle-orm"
@@ -38,6 +39,8 @@ import type { SystemError } from "bun"
import type { Provider } from "@/provider/provider"
import { Effect, Schema } from "effect"
+export const node = LayerNode.group([Database.node])
+
/** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */
interface FetchDecompressionError extends Error {
code: "ZlibError"
diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts
index c0b65b5e57f..4706235f350 100644
--- a/packages/opencode/test/session/messages-pagination.test.ts
+++ b/packages/opencode/test/session/messages-pagination.test.ts
@@ -1,7 +1,8 @@
import { describe, expect, test } from "bun:test"
import { SessionV1 } from "@opencode-ai/core/v1/session"
-import { Database } from "@opencode-ai/core/database/database"
-import { Effect, Layer, Option } from "effect"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
+import { SessionProjector } from "@opencode-ai/core/session/projector"
+import { Effect, Option } from "effect"
import { Session as SessionNs } from "@/session/session"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
@@ -11,7 +12,7 @@ import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
-const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, Database.defaultLayer))
+const it = testEffect(LayerNode.buildLayer(LayerNode.group([SessionNs.node, MessageV2.node, SessionProjector.node])))
const withSession = (
fn: (input: { session: SessionNs.Interface; sessionID: SessionID }) => Effect.Effect,
From f12ac6f234ebe31982ee78f3359e8170cb09ffc9 Mon Sep 17 00:00:00 2001
From: Dax
Date: Sun, 21 Jun 2026 04:24:59 +0200
Subject: [PATCH 028/112] fix(tui): reduce noisy MCP autocomplete matches
(#33176)
---
packages/tui/src/component/prompt/autocomplete.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx
index 6fae556fc1f..f2916173da3 100644
--- a/packages/tui/src/component/prompt/autocomplete.tsx
+++ b/packages/tui/src/component/prompt/autocomplete.tsx
@@ -359,9 +359,8 @@ export function Autocomplete(props: {
const width = props.anchor().width - 4
for (const res of Object.values(sync.data.mcp_resource)) {
- const text = `${res.name} (${res.uri})`
options.push({
- display: Locale.truncateMiddle(text, width),
+ display: Locale.truncateMiddle(res.name, width),
// Match the name only; matching the URI caused unrelated fuzzy hits.
value: res.name,
description: res.description,
@@ -497,6 +496,7 @@ export function Autocomplete(props: {
...(store.visible === "/" ? ["description" as const] : []),
(obj) => obj.aliases?.join(" ") ?? "",
],
+ threshold: store.visible === "@" ? 0.5 : 0,
limit: 10,
scoreFn: (objResults) => {
const displayResult = objResults[0]
From 233d065dd5edbfaabf832e68a6f0a5b3d841ee0b Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Sun, 21 Jun 2026 04:30:25 -0500
Subject: [PATCH 029/112] feat(stats): show model users metric
---
.github/workflows/deploy.yml | 14 ++++++++++++++
packages/stats/app/src/routes/[lab]/[model].tsx | 1 +
packages/stats/core/src/domain/home.ts | 2 ++
3 files changed, 17 insertions(+)
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 18e6cf7acb4..2c326bb6f4a 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -46,3 +46,17 @@ jobs:
SENTRY_RELEASE: web@${{ github.sha }}
VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
VITE_SENTRY_RELEASE: web@${{ github.sha }}
+
+ - run: bun sst shell --stage=${{ github.ref_name }} -- bun run --cwd packages/stats/core db:migrate
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }}
+ PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }}
+ STRIPE_SECRET_KEY: ${{ github.ref_name == 'production' && secrets.STRIPE_SECRET_KEY_PROD || secrets.STRIPE_SECRET_KEY_DEV }}
+ HONEYCOMB_API_KEY: ${{ secrets.HONEYCOMB_API_KEY }}
+ SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
+ SENTRY_ORG: ${{ vars.SENTRY_ORG }}
+ SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }}
+ SENTRY_RELEASE: web@${{ github.sha }}
+ VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
+ VITE_SENTRY_RELEASE: web@${{ github.sha }}
diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx
index 6869764f333..c9b7c33ee98 100644
--- a/packages/stats/app/src/routes/[lab]/[model].tsx
+++ b/packages/stats/app/src/routes/[lab]/[model].tsx
@@ -340,6 +340,7 @@ function ModelOverview(props: { data: StatsModelData | null }) {
{(data) => (
+
0 ? Math.round(current.totalTokens / current.sessions) : 0,
From 6f0e934573b3913c3cd5d015f096fc82da8fa22b Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Sun, 21 Jun 2026 04:38:47 -0500
Subject: [PATCH 030/112] fix(stats): make unique users migration idempotent
---
.github/workflows/deploy.yml | 2 +-
packages/stats/core/package.json | 1 +
packages/stats/core/src/athena.ts | 2 +-
packages/stats/core/src/database.ts | 4 +--
.../stats/core/src/ensure-unique-users.ts | 26 +++++++++++++++++++
5 files changed, 31 insertions(+), 4 deletions(-)
create mode 100644 packages/stats/core/src/ensure-unique-users.ts
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 2c326bb6f4a..22342a0ee4a 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -47,7 +47,7 @@ jobs:
VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
VITE_SENTRY_RELEASE: web@${{ github.sha }}
- - run: bun sst shell --stage=${{ github.ref_name }} -- bun run --cwd packages/stats/core db:migrate
+ - run: bun sst shell --stage=${{ github.ref_name }} -- bun run --cwd packages/stats/core db:ensure-unique-users
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }}
diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json
index c54cb7434cf..38379bc2e9f 100644
--- a/packages/stats/core/package.json
+++ b/packages/stats/core/package.json
@@ -17,6 +17,7 @@
},
"scripts": {
"db:generate": "drizzle-kit generate --config=drizzle.config.ts",
+ "db:ensure-unique-users": "bun src/ensure-unique-users.ts",
"db:migrate": "bun src/migrate.ts",
"db:push": "drizzle-kit push --config=drizzle.config.ts",
"db:studio": "drizzle-kit studio --config=drizzle.config.ts",
diff --git a/packages/stats/core/src/athena.ts b/packages/stats/core/src/athena.ts
index 54037002f57..a2be44ebb76 100644
--- a/packages/stats/core/src/athena.ts
+++ b/packages/stats/core/src/athena.ts
@@ -17,7 +17,7 @@ export type AthenaData = Record
export class AthenaQueryError extends Schema.TaggedErrorClass()("AthenaQueryError", {
message: Schema.String,
queryExecutionId: Schema.optional(Schema.String),
- cause: Schema.optional(Schema.Defect()),
+ cause: Schema.optional(Schema.Defect),
}) {}
export class AthenaQueryTimeoutError extends Schema.TaggedErrorClass()(
diff --git a/packages/stats/core/src/database.ts b/packages/stats/core/src/database.ts
index 9edb717bc71..d265f82bf1e 100644
--- a/packages/stats/core/src/database.ts
+++ b/packages/stats/core/src/database.ts
@@ -45,14 +45,14 @@ export class DrizzleClient extends Context.Service()("@o
}
export class DatabaseError extends Schema.TaggedErrorClass()("DatabaseError", {
- cause: Schema.Defect(),
+ cause: Schema.Defect,
}) {}
export const catchDbError = Effect.mapError((cause) => DatabaseError.make({ cause }))
export class MigrationError extends Schema.TaggedErrorClass()("MigrationError", {
message: Schema.String,
- cause: Schema.optional(Schema.Defect()),
+ cause: Schema.optional(Schema.Defect),
}) {}
export const migrate = Effect.fn("Database.migrate")(function* () {
diff --git a/packages/stats/core/src/ensure-unique-users.ts b/packages/stats/core/src/ensure-unique-users.ts
new file mode 100644
index 00000000000..af610cfb743
--- /dev/null
+++ b/packages/stats/core/src/ensure-unique-users.ts
@@ -0,0 +1,26 @@
+import { Client } from "@planetscale/database"
+import { Resource } from "sst/resource"
+
+const tables = ["geo_stat", "model_stat", "provider_stat"] as const
+
+const client = new Client({ url: Resource.StatsDatabase.url })
+
+await tables.reduce(
+ (promise, table) => promise.then(() => ensureUniqueUsersColumn(table)),
+ Promise.resolve(),
+)
+
+async function ensureUniqueUsersColumn(table: (typeof tables)[number]) {
+ const result = await client.execute<{ column_name: string }>(
+ "SELECT column_name FROM information_schema.columns WHERE table_schema = database() AND table_name = ? AND column_name = 'unique_users'",
+ [table],
+ )
+
+ if (result.rows.length > 0) {
+ console.log(`unique_users column already exists on ${table}`)
+ return
+ }
+
+ await client.execute(`ALTER TABLE \`${table}\` ADD \`unique_users\` bigint NOT NULL DEFAULT 0`)
+ console.log(`added unique_users column to ${table}`)
+}
From d4d841bafdf3255abbf89339a625c160e4f60bc9 Mon Sep 17 00:00:00 2001
From: "opencode-agent[bot]"
Date: Sun, 21 Jun 2026 09:40:15 +0000
Subject: [PATCH 031/112] chore: generate
---
packages/stats/core/src/ensure-unique-users.ts | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/packages/stats/core/src/ensure-unique-users.ts b/packages/stats/core/src/ensure-unique-users.ts
index af610cfb743..d3a7cc6e6ac 100644
--- a/packages/stats/core/src/ensure-unique-users.ts
+++ b/packages/stats/core/src/ensure-unique-users.ts
@@ -5,10 +5,7 @@ const tables = ["geo_stat", "model_stat", "provider_stat"] as const
const client = new Client({ url: Resource.StatsDatabase.url })
-await tables.reduce(
- (promise, table) => promise.then(() => ensureUniqueUsersColumn(table)),
- Promise.resolve(),
-)
+await tables.reduce((promise, table) => promise.then(() => ensureUniqueUsersColumn(table)), Promise.resolve())
async function ensureUniqueUsersColumn(table: (typeof tables)[number]) {
const result = await client.execute<{ column_name: string }>(
From ffcb7542e1a6832e183b7a0d633b5514a70f5f5d Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Sun, 21 Jun 2026 05:00:54 -0500
Subject: [PATCH 032/112] fix(stats): run production migration safely
---
.github/workflows/deploy.yml | 67 ++++++++++++++++++-
packages/stats/core/package.json | 1 +
.../stats/core/src/ensure-unique-users.ts | 55 +++++++++++++--
3 files changed, 115 insertions(+), 8 deletions(-)
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 22342a0ee4a..e000771c47f 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -47,7 +47,8 @@ jobs:
VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
VITE_SENTRY_RELEASE: web@${{ github.sha }}
- - run: bun sst shell --stage=${{ github.ref_name }} -- bun run --cwd packages/stats/core db:ensure-unique-users
+ - if: github.ref_name != 'production'
+ run: bun sst shell --stage=${{ github.ref_name }} -- bun run --cwd packages/stats/core db:ensure-unique-users
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }}
@@ -60,3 +61,67 @@ jobs:
SENTRY_RELEASE: web@${{ github.sha }}
VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
VITE_SENTRY_RELEASE: web@${{ github.sha }}
+
+ - if: github.ref_name == 'production'
+ uses: planetscale/setup-pscale-action@v1
+
+ - if: github.ref_name == 'production'
+ run: |
+ set -euo pipefail
+
+ database="opencode-stats"
+ organization="anomalyco"
+ branch="unique-users-${GITHUB_SHA::12}"
+ password_id=""
+
+ cleanup() {
+ if [ -n "$password_id" ]; then
+ pscale password delete "$database" "$branch" "$password_id" --org "$organization" --force >/dev/null 2>&1 || true
+ fi
+ pscale branch delete "$database" "$branch" --org "$organization" --force >/dev/null 2>&1 || true
+ }
+
+ trap cleanup EXIT
+
+ if bun sst shell --stage=production -- bun run --cwd packages/stats/core db:check-unique-users; then
+ echo "unique_users columns already exist in production"
+ exit 0
+ fi
+
+ pscale branch delete "$database" "$branch" --org "$organization" --force >/dev/null 2>&1 || true
+ pscale branch create "$database" "$branch" --org "$organization" --from production --wait
+
+ response="$(pscale password create "$database" "$branch" "unique-users-${GITHUB_RUN_ID}" --org "$organization" --format json)"
+ password_id="$(echo "$response" | jq -r '.id')"
+
+ export PLANETSCALE_HOST="$(echo "$response" | jq -r '.access_host_url')"
+ export PLANETSCALE_USERNAME="$(echo "$response" | jq -r '.username')"
+ export PLANETSCALE_PASSWORD="$(echo "$response" | jq -r '.plain_text')"
+ export PLANETSCALE_DATABASE="$database"
+
+ echo "::add-mask::$PLANETSCALE_PASSWORD"
+ bun run --cwd packages/stats/core db:ensure-unique-users
+
+ deploy_response="$(pscale deploy-request create "$database" "$branch" --org "$organization" --deploy-to production --format json)"
+ deploy_number="$(echo "$deploy_response" | jq -r '.number')"
+
+ if [ -z "$deploy_number" ] || [ "$deploy_number" = "null" ]; then
+ echo "Could not read deploy request number"
+ exit 1
+ fi
+
+ pscale deploy-request review "$database" "$deploy_number" --org "$organization" --approve || true
+ pscale deploy-request deploy "$database" "$deploy_number" --org "$organization"
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }}
+ PLANETSCALE_SERVICE_TOKEN_ID: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }}
+ PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }}
+ STRIPE_SECRET_KEY: ${{ github.ref_name == 'production' && secrets.STRIPE_SECRET_KEY_PROD || secrets.STRIPE_SECRET_KEY_DEV }}
+ HONEYCOMB_API_KEY: ${{ secrets.HONEYCOMB_API_KEY }}
+ SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
+ SENTRY_ORG: ${{ vars.SENTRY_ORG }}
+ SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }}
+ SENTRY_RELEASE: web@${{ github.sha }}
+ VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
+ VITE_SENTRY_RELEASE: web@${{ github.sha }}
diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json
index 38379bc2e9f..76c022a4946 100644
--- a/packages/stats/core/package.json
+++ b/packages/stats/core/package.json
@@ -17,6 +17,7 @@
},
"scripts": {
"db:generate": "drizzle-kit generate --config=drizzle.config.ts",
+ "db:check-unique-users": "bun src/ensure-unique-users.ts --check",
"db:ensure-unique-users": "bun src/ensure-unique-users.ts",
"db:migrate": "bun src/migrate.ts",
"db:push": "drizzle-kit push --config=drizzle.config.ts",
diff --git a/packages/stats/core/src/ensure-unique-users.ts b/packages/stats/core/src/ensure-unique-users.ts
index d3a7cc6e6ac..cd355083af7 100644
--- a/packages/stats/core/src/ensure-unique-users.ts
+++ b/packages/stats/core/src/ensure-unique-users.ts
@@ -2,22 +2,63 @@ import { Client } from "@planetscale/database"
import { Resource } from "sst/resource"
const tables = ["geo_stat", "model_stat", "provider_stat"] as const
+const checkOnly = process.argv.includes("--check")
-const client = new Client({ url: Resource.StatsDatabase.url })
+const client = new Client({ url: databaseUrl() })
-await tables.reduce((promise, table) => promise.then(() => ensureUniqueUsersColumn(table)), Promise.resolve())
+const missing = await tables.reduce>(
+ async (promise, table) => {
+ const result = await promise
+ if (await hasUniqueUsersColumn(table)) {
+ console.log(`unique_users column already exists on ${table}`)
+ return result
+ }
+ return [...result, table]
+ },
+ Promise.resolve([]),
+)
-async function ensureUniqueUsersColumn(table: (typeof tables)[number]) {
+if (missing.length === 0) {
+ console.log("unique_users columns complete")
+ process.exit(0)
+}
+
+if (checkOnly) {
+ console.log(`unique_users columns missing on ${missing.join(", ")}`)
+ process.exit(1)
+}
+
+await missing.reduce(
+ (promise, table) => promise.then(() => addUniqueUsersColumn(table)),
+ Promise.resolve(),
+)
+
+function databaseUrl() {
+ if (
+ process.env.PLANETSCALE_HOST &&
+ process.env.PLANETSCALE_USERNAME &&
+ process.env.PLANETSCALE_PASSWORD &&
+ process.env.PLANETSCALE_DATABASE
+ )
+ return `mysql://${encodeURIComponent(process.env.PLANETSCALE_USERNAME)}:${encodeURIComponent(
+ process.env.PLANETSCALE_PASSWORD,
+ )}@${process.env.PLANETSCALE_HOST}/${process.env.PLANETSCALE_DATABASE}?ssl=${encodeURIComponent(
+ JSON.stringify({ rejectUnauthorized: true }),
+ )}`
+
+ return process.env.DATABASE_URL ?? Resource.StatsDatabase.url
+}
+
+async function hasUniqueUsersColumn(table: (typeof tables)[number]) {
const result = await client.execute<{ column_name: string }>(
"SELECT column_name FROM information_schema.columns WHERE table_schema = database() AND table_name = ? AND column_name = 'unique_users'",
[table],
)
- if (result.rows.length > 0) {
- console.log(`unique_users column already exists on ${table}`)
- return
- }
+ return result.rows.length > 0
+}
+async function addUniqueUsersColumn(table: (typeof tables)[number]) {
await client.execute(`ALTER TABLE \`${table}\` ADD \`unique_users\` bigint NOT NULL DEFAULT 0`)
console.log(`added unique_users column to ${table}`)
}
From 24ea4dd0087c16d2f45f7404ec86b2ddb4a16077 Mon Sep 17 00:00:00 2001
From: "opencode-agent[bot]"
Date: Sun, 21 Jun 2026 10:03:16 +0000
Subject: [PATCH 033/112] chore: generate
---
.../stats/core/src/ensure-unique-users.ts | 24 +++++++------------
1 file changed, 9 insertions(+), 15 deletions(-)
diff --git a/packages/stats/core/src/ensure-unique-users.ts b/packages/stats/core/src/ensure-unique-users.ts
index cd355083af7..0786476281e 100644
--- a/packages/stats/core/src/ensure-unique-users.ts
+++ b/packages/stats/core/src/ensure-unique-users.ts
@@ -6,17 +6,14 @@ const checkOnly = process.argv.includes("--check")
const client = new Client({ url: databaseUrl() })
-const missing = await tables.reduce>(
- async (promise, table) => {
- const result = await promise
- if (await hasUniqueUsersColumn(table)) {
- console.log(`unique_users column already exists on ${table}`)
- return result
- }
- return [...result, table]
- },
- Promise.resolve([]),
-)
+const missing = await tables.reduce>(async (promise, table) => {
+ const result = await promise
+ if (await hasUniqueUsersColumn(table)) {
+ console.log(`unique_users column already exists on ${table}`)
+ return result
+ }
+ return [...result, table]
+}, Promise.resolve([]))
if (missing.length === 0) {
console.log("unique_users columns complete")
@@ -28,10 +25,7 @@ if (checkOnly) {
process.exit(1)
}
-await missing.reduce(
- (promise, table) => promise.then(() => addUniqueUsersColumn(table)),
- Promise.resolve(),
-)
+await missing.reduce((promise, table) => promise.then(() => addUniqueUsersColumn(table)), Promise.resolve())
function databaseUrl() {
if (
From a97c6de9db785576792495eed0e7dd268533e2a7 Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Sun, 21 Jun 2026 05:16:53 -0500
Subject: [PATCH 034/112] fix(stats): support planetscale cli variants
---
.github/workflows/deploy.yml | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index e000771c47f..0ceaee3d41b 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -102,7 +102,14 @@ jobs:
echo "::add-mask::$PLANETSCALE_PASSWORD"
bun run --cwd packages/stats/core db:ensure-unique-users
- deploy_response="$(pscale deploy-request create "$database" "$branch" --org "$organization" --deploy-to production --format json)"
+ if deploy_response="$(pscale deploy-request create "$database" "$branch" --org "$organization" --into production --format json 2>/tmp/deploy-request-error)"; then
+ :
+ elif deploy_response="$(pscale deploy-request create "$database" "$branch" --org "$organization" --deploy-to production --format json 2>>/tmp/deploy-request-error)"; then
+ :
+ else
+ cat /tmp/deploy-request-error
+ exit 1
+ fi
deploy_number="$(echo "$deploy_response" | jq -r '.number')"
if [ -z "$deploy_number" ] || [ "$deploy_number" = "null" ]; then
From bd8ce5e6a9157d74475e2657825034510105add4 Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Sun, 21 Jun 2026 05:41:10 -0500
Subject: [PATCH 035/112] chore(stats): remove deploy migrations
---
.github/workflows/deploy.yml | 86 ------------------------------------
1 file changed, 86 deletions(-)
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 0ceaee3d41b..18e6cf7acb4 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -46,89 +46,3 @@ jobs:
SENTRY_RELEASE: web@${{ github.sha }}
VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
VITE_SENTRY_RELEASE: web@${{ github.sha }}
-
- - if: github.ref_name != 'production'
- run: bun sst shell --stage=${{ github.ref_name }} -- bun run --cwd packages/stats/core db:ensure-unique-users
- env:
- CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }}
- PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }}
- STRIPE_SECRET_KEY: ${{ github.ref_name == 'production' && secrets.STRIPE_SECRET_KEY_PROD || secrets.STRIPE_SECRET_KEY_DEV }}
- HONEYCOMB_API_KEY: ${{ secrets.HONEYCOMB_API_KEY }}
- SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- SENTRY_ORG: ${{ vars.SENTRY_ORG }}
- SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }}
- SENTRY_RELEASE: web@${{ github.sha }}
- VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
- VITE_SENTRY_RELEASE: web@${{ github.sha }}
-
- - if: github.ref_name == 'production'
- uses: planetscale/setup-pscale-action@v1
-
- - if: github.ref_name == 'production'
- run: |
- set -euo pipefail
-
- database="opencode-stats"
- organization="anomalyco"
- branch="unique-users-${GITHUB_SHA::12}"
- password_id=""
-
- cleanup() {
- if [ -n "$password_id" ]; then
- pscale password delete "$database" "$branch" "$password_id" --org "$organization" --force >/dev/null 2>&1 || true
- fi
- pscale branch delete "$database" "$branch" --org "$organization" --force >/dev/null 2>&1 || true
- }
-
- trap cleanup EXIT
-
- if bun sst shell --stage=production -- bun run --cwd packages/stats/core db:check-unique-users; then
- echo "unique_users columns already exist in production"
- exit 0
- fi
-
- pscale branch delete "$database" "$branch" --org "$organization" --force >/dev/null 2>&1 || true
- pscale branch create "$database" "$branch" --org "$organization" --from production --wait
-
- response="$(pscale password create "$database" "$branch" "unique-users-${GITHUB_RUN_ID}" --org "$organization" --format json)"
- password_id="$(echo "$response" | jq -r '.id')"
-
- export PLANETSCALE_HOST="$(echo "$response" | jq -r '.access_host_url')"
- export PLANETSCALE_USERNAME="$(echo "$response" | jq -r '.username')"
- export PLANETSCALE_PASSWORD="$(echo "$response" | jq -r '.plain_text')"
- export PLANETSCALE_DATABASE="$database"
-
- echo "::add-mask::$PLANETSCALE_PASSWORD"
- bun run --cwd packages/stats/core db:ensure-unique-users
-
- if deploy_response="$(pscale deploy-request create "$database" "$branch" --org "$organization" --into production --format json 2>/tmp/deploy-request-error)"; then
- :
- elif deploy_response="$(pscale deploy-request create "$database" "$branch" --org "$organization" --deploy-to production --format json 2>>/tmp/deploy-request-error)"; then
- :
- else
- cat /tmp/deploy-request-error
- exit 1
- fi
- deploy_number="$(echo "$deploy_response" | jq -r '.number')"
-
- if [ -z "$deploy_number" ] || [ "$deploy_number" = "null" ]; then
- echo "Could not read deploy request number"
- exit 1
- fi
-
- pscale deploy-request review "$database" "$deploy_number" --org "$organization" --approve || true
- pscale deploy-request deploy "$database" "$deploy_number" --org "$organization"
- env:
- CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }}
- PLANETSCALE_SERVICE_TOKEN_ID: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }}
- PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }}
- STRIPE_SECRET_KEY: ${{ github.ref_name == 'production' && secrets.STRIPE_SECRET_KEY_PROD || secrets.STRIPE_SECRET_KEY_DEV }}
- HONEYCOMB_API_KEY: ${{ secrets.HONEYCOMB_API_KEY }}
- SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- SENTRY_ORG: ${{ vars.SENTRY_ORG }}
- SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }}
- SENTRY_RELEASE: web@${{ github.sha }}
- VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
- VITE_SENTRY_RELEASE: web@${{ github.sha }}
From 418a9e4e66c794d7ce2ef49db974274f211d81ab Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Sun, 21 Jun 2026 05:58:39 -0500
Subject: [PATCH 036/112] fix(stats): restore model page rendering
---
packages/stats/app/src/routes/[lab]/[model].tsx | 1 -
packages/stats/core/src/domain/home.ts | 2 --
2 files changed, 3 deletions(-)
diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx
index c9b7c33ee98..6869764f333 100644
--- a/packages/stats/app/src/routes/[lab]/[model].tsx
+++ b/packages/stats/app/src/routes/[lab]/[model].tsx
@@ -340,7 +340,6 @@ function ModelOverview(props: { data: StatsModelData | null }) {
{(data) => (
-
0 ? Math.round(current.totalTokens / current.sessions) : 0,
From c0dc6e50a7244c11028c553505f310a03d427488 Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Sun, 21 Jun 2026 06:16:02 -0500
Subject: [PATCH 037/112] fix(stats): restore worker runtime
---
packages/stats/app/src/routes/[lab]/[model].tsx | 4 ++--
packages/stats/app/src/routes/[lab]/index.tsx | 4 ++--
packages/stats/app/src/routes/api/health.ts | 4 ++--
packages/stats/app/src/routes/index.tsx | 4 ++--
packages/stats/app/src/stats-runtime.ts | 12 ++++++++++++
5 files changed, 20 insertions(+), 8 deletions(-)
create mode 100644 packages/stats/app/src/stats-runtime.ts
diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx
index 6869764f333..f865690df7b 100644
--- a/packages/stats/app/src/routes/[lab]/[model].tsx
+++ b/packages/stats/app/src/routes/[lab]/[model].tsx
@@ -14,7 +14,6 @@ import {
type StatsModelData,
type UsageRange,
} from "@opencode-ai/stats-core/domain/home"
-import { runtime } from "@opencode-ai/stats-core/runtime"
import { createAsync, query, useParams } from "@solidjs/router"
import { createMemo, createSignal, For, onMount, Show, type JSX } from "solid-js"
import { getRequestEvent } from "solid-js/web"
@@ -96,7 +95,8 @@ const worldBorderPath = worldPath(mesh(worldTopology, worldCountryGeometries, (a
const getModelData = query(async (lab: string, model: string) => {
"use server"
- return runtime.runPromise(getStatsModelData(model, lab))
+ const { statsRuntime } = await import("../../stats-runtime")
+ return statsRuntime.runPromise(getStatsModelData(model, lab))
}, "getStatsModelData")
export default function StatsModel() {
diff --git a/packages/stats/app/src/routes/[lab]/index.tsx b/packages/stats/app/src/routes/[lab]/index.tsx
index 1c4cc7cc08a..e1af50fa4ca 100644
--- a/packages/stats/app/src/routes/[lab]/index.tsx
+++ b/packages/stats/app/src/routes/[lab]/index.tsx
@@ -6,7 +6,6 @@ import {
type ModelUsagePoint,
type StatsLabData,
} from "@opencode-ai/stats-core/domain/home"
-import { runtime } from "@opencode-ai/stats-core/runtime"
import { createAsync, query, useParams } from "@solidjs/router"
import { createMemo, createSignal, For, onMount, Show, type JSX } from "solid-js"
import { getRequestEvent } from "solid-js/web"
@@ -46,7 +45,8 @@ const labFooterLinks: readonly HeaderLink[] = [
const getLabData = query(async (lab: string) => {
"use server"
- return runtime.runPromise(getStatsLabData(lab))
+ const { statsRuntime } = await import("../../stats-runtime")
+ return statsRuntime.runPromise(getStatsLabData(lab))
}, "getStatsLabData")
export default function StatsLab() {
diff --git a/packages/stats/app/src/routes/api/health.ts b/packages/stats/app/src/routes/api/health.ts
index fe7abc9a7f0..2a07bfc4e3e 100644
--- a/packages/stats/app/src/routes/api/health.ts
+++ b/packages/stats/app/src/routes/api/health.ts
@@ -1,10 +1,10 @@
import { AppConfig } from "@opencode-ai/stats-core/config"
-import { runtime } from "@opencode-ai/stats-core/runtime"
import { Effect } from "effect"
export async function GET() {
+ const { statsRuntime } = await import("../../stats-runtime")
return Response.json(
- await runtime.runPromise(
+ await statsRuntime.runPromise(
Effect.gen(function* () {
const config = yield* AppConfig
return {
diff --git a/packages/stats/app/src/routes/index.tsx b/packages/stats/app/src/routes/index.tsx
index 862120d033f..2439369b65f 100644
--- a/packages/stats/app/src/routes/index.tsx
+++ b/packages/stats/app/src/routes/index.tsx
@@ -21,7 +21,6 @@ import {
type TokenCostEntry,
type UsagePoint,
} from "@opencode-ai/stats-core/domain/home"
-import { runtime } from "@opencode-ai/stats-core/runtime"
import { createAsync, query } from "@solidjs/router"
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
import { getRequestEvent } from "solid-js/web"
@@ -109,7 +108,8 @@ const worldBorderPath = worldPath(mesh(worldTopology, worldCountryGeometries, (a
const getData = query(async () => {
"use server"
- return runtime.runPromise(getStatsHomeData())
+ const { statsRuntime } = await import("../stats-runtime")
+ return statsRuntime.runPromise(getStatsHomeData())
}, "getStatsHomeData")
export default function StatsHome() {
diff --git a/packages/stats/app/src/stats-runtime.ts b/packages/stats/app/src/stats-runtime.ts
new file mode 100644
index 00000000000..8267f41cc71
--- /dev/null
+++ b/packages/stats/app/src/stats-runtime.ts
@@ -0,0 +1,12 @@
+import { AppConfig } from "@opencode-ai/stats-core/config"
+import { layer } from "@opencode-ai/stats-core/database"
+import { GeoStatRepo } from "@opencode-ai/stats-core/domain/geo"
+import { ModelStatRepo } from "@opencode-ai/stats-core/domain/model"
+import { ProviderStatRepo } from "@opencode-ai/stats-core/domain/provider"
+import { Layer, ManagedRuntime } from "effect"
+
+const repoLayer = Layer.mergeAll(ModelStatRepo.layer, ProviderStatRepo.layer, GeoStatRepo.layer).pipe(
+ Layer.provide(layer),
+)
+
+export const statsRuntime = ManagedRuntime.make(Layer.mergeAll(AppConfig.layer, layer, repoLayer))
From a0aee82be9ae5398479b64626bb4b41e75f31ce6 Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Sun, 21 Jun 2026 06:22:26 -0500
Subject: [PATCH 038/112] fix(stats): inline worker runtime import
---
packages/stats/app/src/routes/[lab]/[model].tsx | 2 +-
packages/stats/app/src/routes/[lab]/index.tsx | 2 +-
packages/stats/app/src/routes/api/health.ts | 2 +-
packages/stats/app/src/routes/index.tsx | 2 +-
4 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx
index f865690df7b..838fa6925c8 100644
--- a/packages/stats/app/src/routes/[lab]/[model].tsx
+++ b/packages/stats/app/src/routes/[lab]/[model].tsx
@@ -26,6 +26,7 @@ import {
type ModelCatalogCost,
type ModelCatalogEntry,
} from "../model-catalog"
+import { statsRuntime } from "../../stats-runtime"
import {
applyThemePreference,
Footer,
@@ -95,7 +96,6 @@ const worldBorderPath = worldPath(mesh(worldTopology, worldCountryGeometries, (a
const getModelData = query(async (lab: string, model: string) => {
"use server"
- const { statsRuntime } = await import("../../stats-runtime")
return statsRuntime.runPromise(getStatsModelData(model, lab))
}, "getStatsModelData")
diff --git a/packages/stats/app/src/routes/[lab]/index.tsx b/packages/stats/app/src/routes/[lab]/index.tsx
index e1af50fa4ca..fc448263971 100644
--- a/packages/stats/app/src/routes/[lab]/index.tsx
+++ b/packages/stats/app/src/routes/[lab]/index.tsx
@@ -16,6 +16,7 @@ import {
type ModelCatalogEntry,
type ModelCatalogLab,
} from "../model-catalog"
+import { statsRuntime } from "../../stats-runtime"
import {
applyThemePreference,
Footer,
@@ -45,7 +46,6 @@ const labFooterLinks: readonly HeaderLink[] = [
const getLabData = query(async (lab: string) => {
"use server"
- const { statsRuntime } = await import("../../stats-runtime")
return statsRuntime.runPromise(getStatsLabData(lab))
}, "getStatsLabData")
diff --git a/packages/stats/app/src/routes/api/health.ts b/packages/stats/app/src/routes/api/health.ts
index 2a07bfc4e3e..b648adb2070 100644
--- a/packages/stats/app/src/routes/api/health.ts
+++ b/packages/stats/app/src/routes/api/health.ts
@@ -1,8 +1,8 @@
import { AppConfig } from "@opencode-ai/stats-core/config"
import { Effect } from "effect"
+import { statsRuntime } from "../../stats-runtime"
export async function GET() {
- const { statsRuntime } = await import("../../stats-runtime")
return Response.json(
await statsRuntime.runPromise(
Effect.gen(function* () {
diff --git a/packages/stats/app/src/routes/index.tsx b/packages/stats/app/src/routes/index.tsx
index 2439369b65f..90ba9ce9fff 100644
--- a/packages/stats/app/src/routes/index.tsx
+++ b/packages/stats/app/src/routes/index.tsx
@@ -26,6 +26,7 @@ import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show,
import { getRequestEvent } from "solid-js/web"
import type { FeatureCollection, GeometryObject, GeoJsonProperties } from "geojson"
import type { GeometryCollection, Topology } from "topojson-specification"
+import { statsRuntime } from "../stats-runtime"
import { findModelCatalogEntry, getModelCatalog, type ModelCatalog } from "./model-catalog"
import {
applyThemePreference,
@@ -108,7 +109,6 @@ const worldBorderPath = worldPath(mesh(worldTopology, worldCountryGeometries, (a
const getData = query(async () => {
"use server"
- const { statsRuntime } = await import("../stats-runtime")
return statsRuntime.runPromise(getStatsHomeData())
}, "getStatsHomeData")
From 1a111be49457c1e274f2bd473c88ec047806925f Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Sun, 21 Jun 2026 06:30:15 -0500
Subject: [PATCH 039/112] fix(stats): run worker effects directly
---
packages/stats/app/src/routes/[lab]/[model].tsx | 4 ++--
packages/stats/app/src/routes/[lab]/index.tsx | 4 ++--
packages/stats/app/src/routes/api/health.ts | 4 ++--
packages/stats/app/src/routes/index.tsx | 4 ++--
packages/stats/app/src/stats-runtime.ts | 8 ++++++--
5 files changed, 14 insertions(+), 10 deletions(-)
diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx
index 838fa6925c8..a068ed0054f 100644
--- a/packages/stats/app/src/routes/[lab]/[model].tsx
+++ b/packages/stats/app/src/routes/[lab]/[model].tsx
@@ -26,7 +26,7 @@ import {
type ModelCatalogCost,
type ModelCatalogEntry,
} from "../model-catalog"
-import { statsRuntime } from "../../stats-runtime"
+import { runStatsEffect } from "../../stats-runtime"
import {
applyThemePreference,
Footer,
@@ -96,7 +96,7 @@ const worldBorderPath = worldPath(mesh(worldTopology, worldCountryGeometries, (a
const getModelData = query(async (lab: string, model: string) => {
"use server"
- return statsRuntime.runPromise(getStatsModelData(model, lab))
+ return runStatsEffect(getStatsModelData(model, lab))
}, "getStatsModelData")
export default function StatsModel() {
diff --git a/packages/stats/app/src/routes/[lab]/index.tsx b/packages/stats/app/src/routes/[lab]/index.tsx
index fc448263971..9d71f7b6442 100644
--- a/packages/stats/app/src/routes/[lab]/index.tsx
+++ b/packages/stats/app/src/routes/[lab]/index.tsx
@@ -16,7 +16,7 @@ import {
type ModelCatalogEntry,
type ModelCatalogLab,
} from "../model-catalog"
-import { statsRuntime } from "../../stats-runtime"
+import { runStatsEffect } from "../../stats-runtime"
import {
applyThemePreference,
Footer,
@@ -46,7 +46,7 @@ const labFooterLinks: readonly HeaderLink[] = [
const getLabData = query(async (lab: string) => {
"use server"
- return statsRuntime.runPromise(getStatsLabData(lab))
+ return runStatsEffect(getStatsLabData(lab))
}, "getStatsLabData")
export default function StatsLab() {
diff --git a/packages/stats/app/src/routes/api/health.ts b/packages/stats/app/src/routes/api/health.ts
index b648adb2070..81e60f82e9a 100644
--- a/packages/stats/app/src/routes/api/health.ts
+++ b/packages/stats/app/src/routes/api/health.ts
@@ -1,10 +1,10 @@
import { AppConfig } from "@opencode-ai/stats-core/config"
import { Effect } from "effect"
-import { statsRuntime } from "../../stats-runtime"
+import { runStatsEffect } from "../../stats-runtime"
export async function GET() {
return Response.json(
- await statsRuntime.runPromise(
+ await runStatsEffect(
Effect.gen(function* () {
const config = yield* AppConfig
return {
diff --git a/packages/stats/app/src/routes/index.tsx b/packages/stats/app/src/routes/index.tsx
index 90ba9ce9fff..1d46d43a136 100644
--- a/packages/stats/app/src/routes/index.tsx
+++ b/packages/stats/app/src/routes/index.tsx
@@ -26,7 +26,7 @@ import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show,
import { getRequestEvent } from "solid-js/web"
import type { FeatureCollection, GeometryObject, GeoJsonProperties } from "geojson"
import type { GeometryCollection, Topology } from "topojson-specification"
-import { statsRuntime } from "../stats-runtime"
+import { runStatsEffect } from "../stats-runtime"
import { findModelCatalogEntry, getModelCatalog, type ModelCatalog } from "./model-catalog"
import {
applyThemePreference,
@@ -109,7 +109,7 @@ const worldBorderPath = worldPath(mesh(worldTopology, worldCountryGeometries, (a
const getData = query(async () => {
"use server"
- return statsRuntime.runPromise(getStatsHomeData())
+ return runStatsEffect(getStatsHomeData())
}, "getStatsHomeData")
export default function StatsHome() {
diff --git a/packages/stats/app/src/stats-runtime.ts b/packages/stats/app/src/stats-runtime.ts
index 8267f41cc71..15beb678b2f 100644
--- a/packages/stats/app/src/stats-runtime.ts
+++ b/packages/stats/app/src/stats-runtime.ts
@@ -3,10 +3,14 @@ import { layer } from "@opencode-ai/stats-core/database"
import { GeoStatRepo } from "@opencode-ai/stats-core/domain/geo"
import { ModelStatRepo } from "@opencode-ai/stats-core/domain/model"
import { ProviderStatRepo } from "@opencode-ai/stats-core/domain/provider"
-import { Layer, ManagedRuntime } from "effect"
+import { Effect, Layer } from "effect"
+import type { Success } from "effect/Layer"
const repoLayer = Layer.mergeAll(ModelStatRepo.layer, ProviderStatRepo.layer, GeoStatRepo.layer).pipe(
Layer.provide(layer),
)
+const statsLayer = Layer.mergeAll(AppConfig.layer, layer, repoLayer)
-export const statsRuntime = ManagedRuntime.make(Layer.mergeAll(AppConfig.layer, layer, repoLayer))
+export function runStatsEffect(effect: Effect.Effect>) {
+ return Effect.runPromise(Effect.provide(effect, statsLayer))
+}
From c6f719e153cfb5fb20daeb4ade588ca9e897e364 Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Sun, 21 Jun 2026 06:39:49 -0500
Subject: [PATCH 040/112] fix(stats): restore worker data exports
---
packages/stats/core/src/domain/home.ts | 39 ++++++++++++++------------
1 file changed, 21 insertions(+), 18 deletions(-)
diff --git a/packages/stats/core/src/domain/home.ts b/packages/stats/core/src/domain/home.ts
index 8427640113b..83a242d6b10 100644
--- a/packages/stats/core/src/domain/home.ts
+++ b/packages/stats/core/src/domain/home.ts
@@ -130,26 +130,28 @@ type ModelAggregate = {
totalCostMicrocents: number
}
-export const getStatsHomeData: () => Effect.Effect<
+export function getStatsHomeData(): Effect.Effect<
StatsHomeData,
DatabaseError,
ModelStatRepo | ProviderStatRepo | GeoStatRepo
-> = Effect.fn("StatsHome.getData")(function* () {
- const modelStats = yield* ModelStatRepo
- const providerStats = yield* ProviderStatRepo
- const geoStats = yield* GeoStatRepo
- const [modelRows, providerRows, geoRows] = yield* Effect.all(
- [modelStats.listDaily(), providerStats.listDaily(), geoStats.listDaily()],
- { concurrency: "unbounded" },
- )
- return buildStatsHomeData(modelRows, providerRows, geoRows)
-})
+> {
+ return Effect.gen(function* () {
+ const modelStats = yield* ModelStatRepo
+ const providerStats = yield* ProviderStatRepo
+ const geoStats = yield* GeoStatRepo
+ const [modelRows, providerRows, geoRows] = yield* Effect.all(
+ [modelStats.listDaily(), providerStats.listDaily(), geoStats.listDaily()],
+ { concurrency: "unbounded" },
+ )
+ return buildStatsHomeData(modelRows, providerRows, geoRows)
+ })
+}
-export const getStatsModelData: (
+export function getStatsModelData(
model: string,
provider?: string,
-) => Effect.Effect = Effect.fn("StatsModel.getData")(
- function* (model, provider) {
+): Effect.Effect {
+ return Effect.gen(function* () {
const modelStats = yield* ModelStatRepo
const geoStats = yield* GeoStatRepo
const modelRows = yield* modelStats.listDaily()
@@ -165,14 +167,15 @@ export const getStatsModelData: (
}),
provider,
)
- },
-)
+ })
+}
-export const getStatsLabData: (provider: string) => Effect.Effect =
- Effect.fn("StatsLab.getData")(function* (provider) {
+export function getStatsLabData(provider: string): Effect.Effect {
+ return Effect.gen(function* () {
const modelStats = yield* ModelStatRepo
return buildStatsLabData(provider, yield* modelStats.listDaily())
})
+}
function buildStatsHomeData(
modelRows: ModelStatMetric[],
From f96e6aa6ed3f0a9c027da4eb7c4fba248426152b Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Sun, 21 Jun 2026 06:48:25 -0500
Subject: [PATCH 041/112] fix(stats): bypass worker runtime crash
---
packages/stats/app/src/routes/api/health.ts | 18 +--
packages/stats/app/src/stats-runtime.ts | 17 +-
packages/stats/core/src/domain/home.ts | 163 +++++++++++++++-----
3 files changed, 128 insertions(+), 70 deletions(-)
diff --git a/packages/stats/app/src/routes/api/health.ts b/packages/stats/app/src/routes/api/health.ts
index 81e60f82e9a..eac4abb8981 100644
--- a/packages/stats/app/src/routes/api/health.ts
+++ b/packages/stats/app/src/routes/api/health.ts
@@ -1,19 +1,3 @@
-import { AppConfig } from "@opencode-ai/stats-core/config"
-import { Effect } from "effect"
-import { runStatsEffect } from "../../stats-runtime"
-
export async function GET() {
- return Response.json(
- await runStatsEffect(
- Effect.gen(function* () {
- const config = yield* AppConfig
- return {
- ok: true,
- app: "stats",
- stage: config.stage,
- publicUrl: config.publicUrl,
- }
- }),
- ),
- )
+ return Response.json({ ok: true, app: "stats" })
}
diff --git a/packages/stats/app/src/stats-runtime.ts b/packages/stats/app/src/stats-runtime.ts
index 15beb678b2f..0fd2da5fdfa 100644
--- a/packages/stats/app/src/stats-runtime.ts
+++ b/packages/stats/app/src/stats-runtime.ts
@@ -1,16 +1,5 @@
-import { AppConfig } from "@opencode-ai/stats-core/config"
-import { layer } from "@opencode-ai/stats-core/database"
-import { GeoStatRepo } from "@opencode-ai/stats-core/domain/geo"
-import { ModelStatRepo } from "@opencode-ai/stats-core/domain/model"
-import { ProviderStatRepo } from "@opencode-ai/stats-core/domain/provider"
-import { Effect, Layer } from "effect"
-import type { Success } from "effect/Layer"
+import { Effect } from "effect"
-const repoLayer = Layer.mergeAll(ModelStatRepo.layer, ProviderStatRepo.layer, GeoStatRepo.layer).pipe(
- Layer.provide(layer),
-)
-const statsLayer = Layer.mergeAll(AppConfig.layer, layer, repoLayer)
-
-export function runStatsEffect(effect: Effect.Effect>) {
- return Effect.runPromise(Effect.provide(effect, statsLayer))
+export function runStatsEffect(effect: Effect.Effect) {
+ return Effect.runPromise(effect)
}
diff --git a/packages/stats/core/src/domain/home.ts b/packages/stats/core/src/domain/home.ts
index 83a242d6b10..c8c4d3e134a 100644
--- a/packages/stats/core/src/domain/home.ts
+++ b/packages/stats/core/src/domain/home.ts
@@ -1,8 +1,9 @@
+import { Client } from "@planetscale/database"
import { Effect } from "effect"
-import { DatabaseError } from "../database"
-import { GeoStatRepo, type GeoStatMetric } from "./geo"
-import { ModelStatRepo, type ModelStatMetric } from "./model"
-import { ProviderStatRepo, type ProviderStatMetric } from "./provider"
+import { Resource } from "sst/resource"
+import type { GeoStatMetric } from "./geo"
+import type { ModelStatMetric } from "./model"
+import type { ProviderStatMetric } from "./provider"
export type UsageProduct = "All Users" | "Zen" | "Go" | "Enterprise"
export type TokenProduct = "Zen" | "Go" | "Enterprise"
@@ -91,6 +92,14 @@ export type StatsHomeData = {
country: Record
}
+export class StatsDataError extends Error {
+ override name = "StatsDataError"
+
+ constructor(readonly cause: unknown) {
+ super("Failed to load stats data")
+ }
+}
+
const DAY_MS = 86_400_000
const TOKEN_SCALE = 1_000_000
const DOLLARS_PER_MICROCENT = 1 / 100_000_000
@@ -130,53 +139,129 @@ type ModelAggregate = {
totalCostMicrocents: number
}
-export function getStatsHomeData(): Effect.Effect<
- StatsHomeData,
- DatabaseError,
- ModelStatRepo | ProviderStatRepo | GeoStatRepo
-> {
- return Effect.gen(function* () {
- const modelStats = yield* ModelStatRepo
- const providerStats = yield* ProviderStatRepo
- const geoStats = yield* GeoStatRepo
- const [modelRows, providerRows, geoRows] = yield* Effect.all(
- [modelStats.listDaily(), providerStats.listDaily(), geoStats.listDaily()],
- { concurrency: "unbounded" },
- )
- return buildStatsHomeData(modelRows, providerRows, geoRows)
+type RawRow = Record
+
+export function getStatsHomeData(): Effect.Effect {
+ return Effect.tryPromise({
+ try: async () => {
+ const [modelRows, providerRows, geoRows] = await Promise.all([listModelDaily(), listProviderDaily(), listGeoDaily()])
+ return buildStatsHomeData(modelRows, providerRows, geoRows)
+ },
+ catch: (cause) => new StatsDataError(cause),
})
}
export function getStatsModelData(
model: string,
provider?: string,
-): Effect.Effect {
- return Effect.gen(function* () {
- const modelStats = yield* ModelStatRepo
- const geoStats = yield* GeoStatRepo
- const modelRows = yield* modelStats.listDaily()
- const normalized = modelRows.flatMap(normalizeStatRow)
- const resolvedModel = resolveModelName(model, normalized, provider)
- if (!resolvedModel) return null
- return buildStatsModelData(
- resolvedModel,
- modelRows,
- yield* geoStats.listDaily({
- model: resolvedModel,
- provider: resolveModelProvider(resolvedModel, normalized, provider),
- }),
- provider,
- )
+): Effect.Effect {
+ return Effect.tryPromise({
+ try: async () => {
+ const modelRows = await listModelDaily()
+ const normalized = modelRows.flatMap(normalizeStatRow)
+ const resolvedModel = resolveModelName(model, normalized, provider)
+ if (!resolvedModel) return null
+ return buildStatsModelData(
+ resolvedModel,
+ modelRows,
+ await listGeoDaily({
+ model: resolvedModel,
+ provider: resolveModelProvider(resolvedModel, normalized, provider),
+ }),
+ provider,
+ )
+ },
+ catch: (cause) => new StatsDataError(cause),
})
}
-export function getStatsLabData(provider: string): Effect.Effect {
- return Effect.gen(function* () {
- const modelStats = yield* ModelStatRepo
- return buildStatsLabData(provider, yield* modelStats.listDaily())
+export function getStatsLabData(provider: string): Effect.Effect {
+ return Effect.tryPromise({
+ try: async () => buildStatsLabData(provider, await listModelDaily()),
+ catch: (cause) => new StatsDataError(cause),
})
}
+async function listModelDaily(): Promise {
+ return (await queryRows(`select period_key, updated_at, tier, provider, model, sessions, unique_users, input_tokens,
+ output_tokens, reasoning_tokens, cache_read_tokens, total_tokens, input_cost_microcents, output_cost_microcents,
+ total_cost_microcents from model_stat where grain = 'day' and client = 'all' and source = 'all'
+ and tier in ('Go', 'go') order by period_key`)).map((row) => ({
+ periodKey: stringValue(row.period_key),
+ updatedAt: dateValue(row.updated_at),
+ tier: stringValue(row.tier),
+ provider: stringValue(row.provider),
+ model: stringValue(row.model),
+ sessions: numberValue(row.sessions),
+ uniqueUsers: numberValue(row.unique_users),
+ inputTokens: numberValue(row.input_tokens),
+ outputTokens: numberValue(row.output_tokens),
+ reasoningTokens: numberValue(row.reasoning_tokens),
+ cacheReadTokens: numberValue(row.cache_read_tokens),
+ totalTokens: numberValue(row.total_tokens),
+ inputCostMicrocents: numberValue(row.input_cost_microcents),
+ outputCostMicrocents: numberValue(row.output_cost_microcents),
+ totalCostMicrocents: numberValue(row.total_cost_microcents),
+ }))
+}
+
+async function listProviderDaily(): Promise {
+ return (await queryRows(`select period_key, updated_at, tier, provider, total_tokens from provider_stat
+ where grain = 'day' and client = 'all' and source = 'all' and tier in ('Go', 'go') order by period_key`)).map(
+ (row) => ({
+ periodKey: stringValue(row.period_key),
+ updatedAt: dateValue(row.updated_at),
+ tier: stringValue(row.tier),
+ provider: stringValue(row.provider),
+ totalTokens: numberValue(row.total_tokens),
+ }),
+ )
+}
+
+async function listGeoDaily(opts?: { provider?: string; model?: string }): Promise {
+ const scope =
+ opts?.model && opts.provider
+ ? "and provider = ? and model = ?"
+ : opts?.model
+ ? "and model = ?"
+ : "and provider = 'all' and model = 'all'"
+ const params = opts?.model && opts.provider ? [opts.provider, opts.model] : opts?.model ? [opts.model] : []
+ return (await queryRows(
+ `select period_key, updated_at, tier, provider, model, country, continent, total_tokens from geo_stat
+ where grain = 'day' and client = 'all' and source = 'all' and tier in ('Go', 'go') ${scope} order by period_key`,
+ params,
+ )).map((row) => ({
+ periodKey: stringValue(row.period_key),
+ updatedAt: dateValue(row.updated_at),
+ tier: stringValue(row.tier),
+ provider: stringValue(row.provider),
+ model: stringValue(row.model),
+ country: stringValue(row.country),
+ continent: stringValue(row.continent),
+ totalTokens: numberValue(row.total_tokens),
+ }))
+}
+
+async function queryRows(query: string, params: string[] = []) {
+ return (await new Client({ url: databaseUrl() }).execute(query, params)).rows as RawRow[]
+}
+
+function databaseUrl() {
+ return process.env.DATABASE_URL ?? Resource.StatsDatabase.url
+}
+
+function stringValue(value: unknown) {
+ return value == null ? "" : String(value)
+}
+
+function numberValue(value: unknown) {
+ return Number(value ?? 0)
+}
+
+function dateValue(value: unknown) {
+ return value instanceof Date ? value : new Date(stringValue(value))
+}
+
function buildStatsHomeData(
modelRows: ModelStatMetric[],
providerRows: ProviderStatMetric[],
From 7a9337da8a8a83991a22dd462a09faef10c35546 Mon Sep 17 00:00:00 2001
From: "opencode-agent[bot]"
Date: Sun, 21 Jun 2026 11:49:50 +0000
Subject: [PATCH 042/112] chore: generate
---
packages/stats/core/src/domain/home.ts | 42 +++++++++++++++-----------
1 file changed, 25 insertions(+), 17 deletions(-)
diff --git a/packages/stats/core/src/domain/home.ts b/packages/stats/core/src/domain/home.ts
index c8c4d3e134a..0a152af29fa 100644
--- a/packages/stats/core/src/domain/home.ts
+++ b/packages/stats/core/src/domain/home.ts
@@ -144,7 +144,11 @@ type RawRow = Record
export function getStatsHomeData(): Effect.Effect {
return Effect.tryPromise({
try: async () => {
- const [modelRows, providerRows, geoRows] = await Promise.all([listModelDaily(), listProviderDaily(), listGeoDaily()])
+ const [modelRows, providerRows, geoRows] = await Promise.all([
+ listModelDaily(),
+ listProviderDaily(),
+ listGeoDaily(),
+ ])
return buildStatsHomeData(modelRows, providerRows, geoRows)
},
catch: (cause) => new StatsDataError(cause),
@@ -183,10 +187,12 @@ export function getStatsLabData(provider: string): Effect.Effect {
- return (await queryRows(`select period_key, updated_at, tier, provider, model, sessions, unique_users, input_tokens,
+ return (
+ await queryRows(`select period_key, updated_at, tier, provider, model, sessions, unique_users, input_tokens,
output_tokens, reasoning_tokens, cache_read_tokens, total_tokens, input_cost_microcents, output_cost_microcents,
total_cost_microcents from model_stat where grain = 'day' and client = 'all' and source = 'all'
- and tier in ('Go', 'go') order by period_key`)).map((row) => ({
+ and tier in ('Go', 'go') order by period_key`)
+ ).map((row) => ({
periodKey: stringValue(row.period_key),
updatedAt: dateValue(row.updated_at),
tier: stringValue(row.tier),
@@ -206,16 +212,16 @@ async function listModelDaily(): Promise {
}
async function listProviderDaily(): Promise {
- return (await queryRows(`select period_key, updated_at, tier, provider, total_tokens from provider_stat
- where grain = 'day' and client = 'all' and source = 'all' and tier in ('Go', 'go') order by period_key`)).map(
- (row) => ({
- periodKey: stringValue(row.period_key),
- updatedAt: dateValue(row.updated_at),
- tier: stringValue(row.tier),
- provider: stringValue(row.provider),
- totalTokens: numberValue(row.total_tokens),
- }),
- )
+ return (
+ await queryRows(`select period_key, updated_at, tier, provider, total_tokens from provider_stat
+ where grain = 'day' and client = 'all' and source = 'all' and tier in ('Go', 'go') order by period_key`)
+ ).map((row) => ({
+ periodKey: stringValue(row.period_key),
+ updatedAt: dateValue(row.updated_at),
+ tier: stringValue(row.tier),
+ provider: stringValue(row.provider),
+ totalTokens: numberValue(row.total_tokens),
+ }))
}
async function listGeoDaily(opts?: { provider?: string; model?: string }): Promise {
@@ -226,11 +232,13 @@ async function listGeoDaily(opts?: { provider?: string; model?: string }): Promi
? "and model = ?"
: "and provider = 'all' and model = 'all'"
const params = opts?.model && opts.provider ? [opts.provider, opts.model] : opts?.model ? [opts.model] : []
- return (await queryRows(
- `select period_key, updated_at, tier, provider, model, country, continent, total_tokens from geo_stat
+ return (
+ await queryRows(
+ `select period_key, updated_at, tier, provider, model, country, continent, total_tokens from geo_stat
where grain = 'day' and client = 'all' and source = 'all' and tier in ('Go', 'go') ${scope} order by period_key`,
- params,
- )).map((row) => ({
+ params,
+ )
+ ).map((row) => ({
periodKey: stringValue(row.period_key),
updatedAt: dateValue(row.updated_at),
tier: stringValue(row.tier),
From c780d7cee7321353c6196796f34e4d3120e2d4ee Mon Sep 17 00:00:00 2001
From: Dax
Date: Sun, 21 Jun 2026 14:05:49 +0200
Subject: [PATCH 043/112] feat(plugin): add v2 effect host (#33111)
---
bun.lock | 2 +
packages/core/package.json | 1 +
packages/core/src/agent.ts | 23 +-
packages/core/src/catalog.ts | 144 ++---
packages/core/src/command.ts | 21 +-
packages/core/src/config/plugin/agent.ts | 122 ++--
packages/core/src/config/plugin/command.ts | 65 +-
packages/core/src/config/plugin/provider.ts | 217 +++----
packages/core/src/config/plugin/reference.ts | 81 ++-
packages/core/src/config/plugin/skill.ts | 69 +--
packages/core/src/integration.ts | 98 +--
packages/core/src/model-request.ts | 2 +-
packages/core/src/model.ts | 19 +-
packages/core/src/npm.ts | 50 +-
packages/core/src/permission/schema.ts | 2 +-
packages/core/src/plugin.ts | 63 +-
packages/core/src/plugin/agent.ts | 29 +-
packages/core/src/plugin/boot.ts | 110 ++--
packages/core/src/plugin/command.ts | 24 +-
packages/core/src/plugin/host.ts | 236 +++++++
packages/core/src/plugin/models-dev.ts | 45 +-
packages/core/src/plugin/provider/alibaba.ts | 15 +-
.../src/plugin/provider/amazon-bedrock.ts | 24 +-
.../core/src/plugin/provider/anthropic.ts | 19 +-
packages/core/src/plugin/provider/azure.ts | 41 +-
packages/core/src/plugin/provider/cerebras.ts | 23 +-
.../plugin/provider/cloudflare-ai-gateway.ts | 15 +-
.../plugin/provider/cloudflare-workers-ai.ts | 24 +-
packages/core/src/plugin/provider/cohere.ts | 15 +-
.../core/src/plugin/provider/deepinfra.ts | 15 +-
packages/core/src/plugin/provider/dynamic.ts | 21 +-
packages/core/src/plugin/provider/gateway.ts | 15 +-
.../src/plugin/provider/github-copilot.ts | 40 +-
packages/core/src/plugin/provider/gitlab.ts | 20 +-
.../core/src/plugin/provider/google-vertex.ts | 46 +-
packages/core/src/plugin/provider/google.ts | 15 +-
packages/core/src/plugin/provider/groq.ts | 15 +-
packages/core/src/plugin/provider/kilo.ts | 14 +-
.../core/src/plugin/provider/llmgateway.ts | 18 +-
packages/core/src/plugin/provider/mistral.ts | 15 +-
packages/core/src/plugin/provider/nvidia.ts | 14 +-
.../src/plugin/provider/openai-compatible.ts | 15 +-
packages/core/src/plugin/provider/openai.ts | 44 +-
packages/core/src/plugin/provider/opencode.ts | 18 +-
.../core/src/plugin/provider/openrouter.ts | 19 +-
.../core/src/plugin/provider/perplexity.ts | 15 +-
.../core/src/plugin/provider/sap-ai-core.ts | 26 +-
.../src/plugin/provider/snowflake-cortex.ts | 15 +-
.../core/src/plugin/provider/togetherai.ts | 15 +-
packages/core/src/plugin/provider/venice.ts | 15 +-
packages/core/src/plugin/provider/vercel.ts | 19 +-
packages/core/src/plugin/provider/xai.ts | 20 +-
packages/core/src/plugin/provider/zenmux.ts | 14 +-
packages/core/src/plugin/skill.ts | 15 +-
packages/core/src/provider.ts | 8 +-
packages/core/src/reference.ts | 21 +-
packages/core/src/session/runner/model.ts | 12 +-
packages/core/src/skill.ts | 17 +-
packages/core/src/state.ts | 160 ++---
packages/core/src/tool/application-tools.ts | 14 +-
packages/core/test/agent.test.ts | 43 +-
packages/core/test/catalog.test.ts | 186 ++----
packages/core/test/command.test.ts | 3 +-
packages/core/test/config/agent.test.ts | 20 +-
packages/core/test/config/command.test.ts | 4 +-
packages/core/test/config/provider.test.ts | 31 +-
packages/core/test/config/skill.test.ts | 42 +-
packages/core/test/integration.test.ts | 22 +-
packages/core/test/location-layer.test.ts | 56 +-
packages/core/test/npm.test.ts | 2 +-
packages/core/test/permission.test.ts | 12 +-
packages/core/test/plugin.test.ts | 17 +-
packages/core/test/plugin/command.test.ts | 12 +-
packages/core/test/plugin/host.ts | 317 ++++++++++
packages/core/test/plugin/models-dev.test.ts | 12 +-
.../core/test/plugin/provider-alibaba.test.ts | 10 +-
.../plugin/provider-amazon-bedrock.test.ts | 41 +-
.../test/plugin/provider-anthropic.test.ts | 22 +-
.../provider-azure-cognitive-services.test.ts | 24 +-
.../core/test/plugin/provider-azure.test.ts | 103 +--
.../test/plugin/provider-cerebras.test.ts | 22 +-
.../provider-cloudflare-ai-gateway.test.ts | 24 +-
.../provider-cloudflare-workers-ai.test.ts | 96 +--
.../core/test/plugin/provider-cohere.test.ts | 8 +-
.../test/plugin/provider-deepinfra.test.ts | 12 +-
.../core/test/plugin/provider-dynamic.test.ts | 18 +-
.../core/test/plugin/provider-gateway.test.ts | 8 +-
.../plugin/provider-github-copilot.test.ts | 28 +-
.../core/test/plugin/provider-gitlab.test.ts | 117 +---
.../provider-google-vertex-anthropic.test.ts | 32 +-
.../plugin/provider-google-vertex.test.ts | 50 +-
.../core/test/plugin/provider-google.test.ts | 8 +-
.../core/test/plugin/provider-groq.test.ts | 12 +-
packages/core/test/plugin/provider-helper.ts | 40 +-
.../core/test/plugin/provider-kilo.test.ts | 27 +-
.../test/plugin/provider-llmgateway.test.ts | 26 +-
.../core/test/plugin/provider-mistral.test.ts | 12 +-
.../core/test/plugin/provider-nvidia.test.ts | 25 +-
.../plugin/provider-openai-compatible.test.ts | 10 +-
.../core/test/plugin/provider-openai.test.ts | 21 +-
.../test/plugin/provider-opencode.test.ts | 85 ++-
.../test/plugin/provider-openrouter.test.ts | 31 +-
.../test/plugin/provider-perplexity.test.ts | 12 +-
.../test/plugin/provider-sap-ai-core.test.ts | 9 +-
.../plugin/provider-snowflake-cortex.test.ts | 14 +-
.../test/plugin/provider-togetherai.test.ts | 10 +-
.../core/test/plugin/provider-venice.test.ts | 10 +-
.../core/test/plugin/provider-vercel.test.ts | 27 +-
.../core/test/plugin/provider-xai.test.ts | 32 +-
.../core/test/plugin/provider-zenmux.test.ts | 30 +-
packages/core/test/plugin/skill.test.ts | 3 +-
packages/core/test/reference.test.ts | 9 +-
.../core/test/session-runner-model.test.ts | 2 +-
packages/core/test/session-runner.test.ts | 8 +-
packages/core/test/skill.test.ts | 8 +-
packages/core/test/state.test.ts | 87 ++-
packages/core/test/tool-skill.test.ts | 4 +-
packages/opencode/src/cli/cmd/debug/v2.ts | 8 +-
packages/plugin/package.json | 4 +-
packages/plugin/src/v2/effect/PLAN.md | 515 +++++++++++++++
packages/plugin/src/v2/effect/README.md | 585 ++++++++++++++++++
packages/plugin/src/v2/effect/agent.ts | 17 +
packages/plugin/src/v2/effect/aisdk.ts | 21 +
packages/plugin/src/v2/effect/catalog.ts | 41 ++
packages/plugin/src/v2/effect/command.ts | 15 +
packages/plugin/src/v2/effect/event.ts | 10 +
packages/plugin/src/v2/effect/filesystem.ts | 17 +
packages/plugin/src/v2/effect/host.ts | 27 +
packages/plugin/src/v2/effect/index.ts | 17 +
packages/plugin/src/v2/effect/integration.ts | 36 ++
packages/plugin/src/v2/effect/location.ts | 6 +
packages/plugin/src/v2/effect/npm.ts | 11 +
packages/plugin/src/v2/effect/path.ts | 8 +
packages/plugin/src/v2/effect/plugin.ts | 11 +
packages/plugin/src/v2/effect/reference.ts | 13 +
packages/plugin/src/v2/effect/registration.ts | 16 +
packages/plugin/src/v2/effect/skill.ts | 18 +
packages/sdk/js/package.json | 3 +-
packages/server/src/handlers/provider.ts | 17 +-
139 files changed, 3749 insertions(+), 1952 deletions(-)
create mode 100644 packages/core/src/plugin/host.ts
create mode 100644 packages/core/test/plugin/host.ts
create mode 100644 packages/plugin/src/v2/effect/PLAN.md
create mode 100644 packages/plugin/src/v2/effect/README.md
create mode 100644 packages/plugin/src/v2/effect/agent.ts
create mode 100644 packages/plugin/src/v2/effect/aisdk.ts
create mode 100644 packages/plugin/src/v2/effect/catalog.ts
create mode 100644 packages/plugin/src/v2/effect/command.ts
create mode 100644 packages/plugin/src/v2/effect/event.ts
create mode 100644 packages/plugin/src/v2/effect/filesystem.ts
create mode 100644 packages/plugin/src/v2/effect/host.ts
create mode 100644 packages/plugin/src/v2/effect/index.ts
create mode 100644 packages/plugin/src/v2/effect/integration.ts
create mode 100644 packages/plugin/src/v2/effect/location.ts
create mode 100644 packages/plugin/src/v2/effect/npm.ts
create mode 100644 packages/plugin/src/v2/effect/path.ts
create mode 100644 packages/plugin/src/v2/effect/plugin.ts
create mode 100644 packages/plugin/src/v2/effect/reference.ts
create mode 100644 packages/plugin/src/v2/effect/registration.ts
create mode 100644 packages/plugin/src/v2/effect/skill.ts
diff --git a/bun.lock b/bun.lock
index e38e531d994..657424dfef1 100644
--- a/bun.lock
+++ b/bun.lock
@@ -276,6 +276,7 @@
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/llm": "workspace:*",
+ "@opencode-ai/plugin": "workspace:*",
"@openrouter/ai-sdk-provider": "2.9.0",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
@@ -625,6 +626,7 @@
"name": "@opencode-ai/plugin",
"version": "1.17.9",
"dependencies": {
+ "@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
"zod": "catalog:",
diff --git a/packages/core/package.json b/packages/core/package.json
index 7e12e5bac1f..2aec5ecc7e5 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -91,6 +91,7 @@
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/llm": "workspace:*",
+ "@opencode-ai/plugin": "workspace:*",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts
index fabf7477d68..18e9e59c0ff 100644
--- a/packages/core/src/agent.ts
+++ b/packages/core/src/agent.ts
@@ -1,7 +1,6 @@
export * as AgentV2 from "./agent"
-import { Array, Context, Effect, Layer, Schema, Scope } from "effect"
-import { castDraft, enableMapSet, type Draft } from "immer"
+import { Array, Context, Effect, Layer, Schema, Scope, Types } from "effect"
import { ModelV2 } from "./model"
import { PermissionSchema } from "./permission/schema"
import { ProviderV2 } from "./provider"
@@ -49,21 +48,19 @@ export interface Selection {
}
type Data = {
- agents: Map
+ agents: Map>
default?: ID
}
-export type Editor = {
+export type Draft = {
list: () => readonly Info[]
get: (id: ID) => Info | undefined
default: (id: ID | undefined) => void
- update: (id: ID, fn: (agent: Draft) => void) => void
+ update: (id: ID, fn: (agent: Types.DeepMutable) => void) => void
remove: (id: ID) => void
}
-export interface Interface {
- readonly transform: State.Interface["transform"]
- readonly update: State.Interface["update"]
+export interface Interface extends State.Transformable {
readonly get: (id: ID) => Effect.Effect
readonly default: () => Effect.Effect
readonly resolve: (id?: ID | string) => Effect.Effect
@@ -73,21 +70,19 @@ export interface Interface {
export class Service extends Context.Service()("@opencode/v2/Agent") {}
-enableMapSet()
-
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
- const state = State.create({
+ const state = State.create({
initial: () => ({ agents: new Map() }),
- editor: (draft) => ({
+ draft: (draft) => ({
list: () => Array.fromIterable(draft.agents.values()) as Info[],
get: (id) => draft.agents.get(id),
default: (id) => {
draft.default = id
},
update: (id, fn) => {
- const current = draft.agents.get(id) ?? castDraft(Info.empty(id))
+ const current = draft.agents.get(id) ?? (Info.empty(id) as Types.DeepMutable)
if (!draft.agents.has(id)) draft.agents.set(id, current)
fn(current)
current.id = id
@@ -113,7 +108,7 @@ export const layer = Layer.effect(
return Service.of({
transform: state.transform,
- update: state.update,
+ rebuild: state.rebuild,
get: Effect.fn("AgentV2.get")(function* (id) {
return state.get().agents.get(id)
}),
diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts
index 4156db5c361..d32f9366811 100644
--- a/packages/core/src/catalog.ts
+++ b/packages/core/src/catalog.ts
@@ -1,36 +1,21 @@
export * as Catalog from "./catalog"
-import { Array, Context, Effect, Layer, Option, Order, pipe, Schema, Scope, Stream } from "effect"
-import { castDraft, enableMapSet, type Draft } from "immer"
+import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect"
import { ModelV2 } from "./model"
import { ModelRequest } from "./model-request"
-import { PluginV2 } from "./plugin"
import { ProviderV2 } from "./provider"
-import { Location } from "./location"
import { EventV2 } from "./event"
import { Policy } from "./policy"
import { State } from "./state"
import { Integration } from "./integration"
export type ProviderRecord = {
- provider: ProviderV2.Info
- models: Map
+ provider: ProviderV2.MutableInfo
+ models: Map
}
export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID }
-export class ProviderNotFoundError extends Schema.TaggedErrorClass()(
- "CatalogV2.ProviderNotFound",
- {
- providerID: ProviderV2.ID,
- },
-) {}
-
-export class ModelNotFoundError extends Schema.TaggedErrorClass()("CatalogV2.ModelNotFound", {
- providerID: ProviderV2.ID,
- modelID: ModelV2.ID,
-}) {}
-
export const PolicyActions = Schema.Literals(["provider.use"])
export const Event = {
@@ -42,16 +27,16 @@ type Data = {
defaultModel?: DefaultModel
}
-export type Editor = {
+export type Draft = {
provider: {
list: () => readonly ProviderRecord[]
get: (providerID: ProviderV2.ID) => ProviderRecord | undefined
- update: (providerID: ProviderV2.ID, fn: (provider: Draft) => void) => void
+ update: (providerID: ProviderV2.ID, fn: (provider: ProviderV2.MutableInfo) => void) => void
remove: (providerID: ProviderV2.ID) => void
}
model: {
get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => ModelV2.Info | undefined
- update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft) => void) => void
+ update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: ModelV2.MutableInfo) => void) => void
remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
default: {
get: () => DefaultModel | undefined
@@ -60,38 +45,29 @@ export type Editor = {
}
}
-export interface Interface {
- readonly transform: State.Interface["transform"]
+export interface Interface extends State.Transformable {
readonly provider: {
- readonly get: (providerID: ProviderV2.ID) => Effect.Effect
+ readonly get: (providerID: ProviderV2.ID) => Effect.Effect
readonly all: () => Effect.Effect
readonly available: () => Effect.Effect
}
readonly model: {
- readonly get: (
- providerID: ProviderV2.ID,
- modelID: ModelV2.ID,
- ) => Effect.Effect
+ readonly get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => Effect.Effect
readonly all: () => Effect.Effect
readonly available: () => Effect.Effect
- readonly default: () => Effect.Effect>
- readonly small: (providerID: ProviderV2.ID) => Effect.Effect>
+ readonly default: () => Effect.Effect
+ readonly small: (providerID: ProviderV2.ID) => Effect.Effect
}
}
export class Service extends Context.Service()("@opencode/v2/Catalog") {}
-enableMapSet()
-
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
- const location = yield* Location.Service
- const plugin = yield* PluginV2.Service
const events = yield* EventV2.Service
const policy = yield* Policy.Service
const integrations = yield* Integration.Service
- const scope = yield* Scope.Scope
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined, connected: boolean) => {
if (provider.disabled) return false
@@ -120,32 +96,26 @@ export const layer = Layer.effect(
})
}
- function* getRecord(providerID: ProviderV2.ID) {
- const match = state.get().providers.get(providerID)
- if (!match) return yield* new ProviderNotFoundError({ providerID })
- return match
- }
-
- const normalizeApi = (item: Draft | Draft) => {
+ const normalizeApi = (item: ProviderV2.MutableInfo | ModelV2.MutableInfo) => {
if (typeof item.request.body.baseURL !== "string") return
item.api.url = item.request.body.baseURL
delete item.request.body.baseURL
}
- const state = State.create({
+ const state = State.create({
initial: () => ({ providers: new Map() }),
- editor: (draft) => {
- const result: Editor = {
+ draft: (draft) => {
+ const result: Draft = {
provider: {
list: () => Array.fromIterable(draft.providers.values()) as ProviderRecord[],
get: (providerID) => draft.providers.get(providerID),
update: (providerID, fn) => {
let current = draft.providers.get(providerID)
if (!current) {
- current = castDraft({
- provider: ProviderV2.Info.empty(providerID),
- models: new Map(),
- })
+ current = {
+ provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo,
+ models: new Map(),
+ }
draft.providers.set(providerID, current)
}
fn(current.provider)
@@ -160,13 +130,14 @@ export const layer = Layer.effect(
update: (providerID, modelID, fn) => {
let record = draft.providers.get(providerID)
if (!record) {
- record = castDraft({
- provider: ProviderV2.Info.empty(providerID),
- models: new Map(),
- })
+ record = {
+ provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo,
+ models: new Map(),
+ }
draft.providers.set(providerID, record)
}
- const model = record.models.get(modelID) ?? castDraft(ModelV2.Info.empty(providerID, modelID))
+ const model =
+ record.models.get(modelID) ?? (ModelV2.Info.empty(providerID, modelID) as ModelV2.MutableInfo)
if (!record.models.has(modelID)) record.models.set(modelID, model)
fn(model)
model.id = modelID
@@ -186,8 +157,7 @@ export const layer = Layer.effect(
}
return result
},
- finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) {
- if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid)
+ finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) {
if (policy.hasStatements()) {
for (const record of [...catalog.provider.list()]) {
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
@@ -198,25 +168,13 @@ export const layer = Layer.effect(
yield* events.publish(Event.Updated, {})
}),
})
- yield* events.subscribe(PluginV2.Event.Added).pipe(
- // Plugin registries are location scoped even though the event bus is process scoped.
- Stream.filter(
- (event) =>
- event.location?.directory === location.directory && event.location.workspaceID === location.workspaceID,
- ),
- Stream.runForEach((event) =>
- state.mutate((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"),
- ),
- Effect.forkIn(scope, { startImmediately: true }),
- )
-
const result: Interface = {
transform: state.transform,
+ rebuild: state.rebuild,
provider: {
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
- const record = yield* getRecord(providerID)
- return record.provider
+ return state.get().providers.get(providerID)?.provider
}),
all: Effect.fn("CatalogV2.provider.all")(function* () {
@@ -238,10 +196,10 @@ export const layer = Layer.effect(
model: {
get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) {
- const record = yield* getRecord(providerID)
+ const record = state.get().providers.get(providerID)
+ if (!record) return
const model = record.models.get(modelID)
- if (!model) return yield* new ModelNotFoundError({ providerID, modelID })
- return projectModel(model, record.provider)
+ return model && projectModel(model, record.provider)
}),
all: Effect.fn("CatalogV2.model.all")(function* () {
@@ -250,7 +208,7 @@ export const layer = Layer.effect(
Array.flatMap((record) => {
return Array.fromIterable(record.models.values()).map((model) => projectModel(model, record.provider))
}),
- Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
+ Array.sortWith((item) => item.time.released, Order.flip(Order.Number)),
)
}),
@@ -262,31 +220,30 @@ export const layer = Layer.effect(
default: Effect.fn("CatalogV2.model.default")(function* () {
const defaultModel = state.get().defaultModel
if (defaultModel) {
- const provider = yield* result.provider.get(defaultModel.providerID).pipe(Effect.option)
- if (
- Option.isSome(provider) &&
- (yield* result.provider.available()).some((item) => item.id === provider.value.id)
- ) {
- const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option)
- if (Option.isSome(model) && model.value.enabled) return model
+ const provider = yield* result.provider.get(defaultModel.providerID)
+ if (provider && (yield* result.provider.available()).some((item) => item.id === provider.id)) {
+ const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID)
+ if (model?.enabled) return model
}
}
- return pipe(
- yield* result.model.available(),
- Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
- Array.head,
+ return Option.getOrUndefined(
+ pipe(
+ yield* result.model.available(),
+ Array.sortWith((item) => item.time.released, Order.flip(Order.Number)),
+ Array.head,
+ ),
)
}),
small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
const record = state.get().providers.get(providerID)
- if (!record) return Option.none()
+ if (!record) return
const provider = record.provider
if (providerID === ProviderV2.ID.opencode) {
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
- if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(projectModel(gpt5Nano, provider))
+ if (gpt5Nano?.enabled && gpt5Nano.status === "active") return projectModel(gpt5Nano, provider)
}
const candidates = pipe(
@@ -302,7 +259,7 @@ export const layer = Layer.effect(
Array.map((model) => ({
model,
cost: model.cost[0] ? model.cost[0].input + model.cost[0].output : 999,
- age: (Date.now() - model.time.released.epochMilliseconds) / (1000 * 60 * 60 * 24 * 30),
+ age: (Date.now() - model.time.released) / (1000 * 60 * 60 * 24 * 30),
small: SMALL_MODEL_RE.test(`${model.id} ${model.family ?? ""} ${model.name}`.toLowerCase()),
})),
Array.filter((item) => item.cost > 0 && item.age <= 18),
@@ -319,10 +276,12 @@ export const layer = Layer.effect(
)
}
- return pipe(
- candidates,
- Array.filter((item) => item.small),
- (items) => (items.length > 0 ? pick(items) : pick(candidates)),
+ return Option.getOrUndefined(
+ pipe(
+ candidates,
+ Array.filter((item) => item.small),
+ (items) => (items.length > 0 ? pick(items) : pick(candidates)),
+ ),
)
}),
},
@@ -336,6 +295,5 @@ const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
export const locationLayer = layer.pipe(
Layer.provideMerge(Integration.locationLayer),
- Layer.provideMerge(PluginV2.locationLayer),
Layer.provideMerge(Policy.locationLayer),
)
diff --git a/packages/core/src/command.ts b/packages/core/src/command.ts
index f6b8210be12..622702e9946 100644
--- a/packages/core/src/command.ts
+++ b/packages/core/src/command.ts
@@ -1,7 +1,6 @@
export * as CommandV2 from "./command"
-import { Context, Effect, Layer, Schema } from "effect"
-import { castDraft, type Draft } from "immer"
+import { Context, Effect, Layer, Schema, Types } from "effect"
import { ModelV2 } from "./model"
import { State } from "./state"
@@ -15,19 +14,17 @@ export class Info extends Schema.Class("CommandV2.Info")({
}) {}
export type Data = {
- commands: Map
+ commands: Map>
}
-export type Editor = {
+export type Draft = {
list: () => readonly Info[]
get: (name: string) => Info | undefined
- update: (name: string, update: (command: Draft) => void) => void
+ update: (name: string, update: (command: Types.DeepMutable) => void) => void
remove: (name: string) => void
}
-export interface Interface {
- readonly transform: State.Interface["transform"]
- readonly update: State.Interface["update"]
+export interface Interface extends State.Transformable {
readonly get: (name: string) => Effect.Effect
readonly list: () => Effect.Effect
}
@@ -37,13 +34,13 @@ export class Service extends Context.Service()("@opencode/v2
export const layer = Layer.effect(
Service,
Effect.sync(() => {
- const state = State.create({
+ const state = State.create({
initial: () => ({ commands: new Map() }),
- editor: (draft) => ({
+ draft: (draft) => ({
list: () => Array.from(draft.commands.values()) as Info[],
get: (name) => draft.commands.get(name),
update: (name, update) => {
- const current = draft.commands.get(name) ?? castDraft(new Info({ name, template: "" }))
+ const current = draft.commands.get(name) ?? (new Info({ name, template: "" }) as Types.DeepMutable)
if (!draft.commands.has(name)) draft.commands.set(name, current)
update(current)
current.name = name
@@ -55,7 +52,7 @@ export const layer = Layer.effect(
})
return Service.of({
- update: state.update,
+ rebuild: state.rebuild,
transform: state.transform,
get: Effect.fn("CommandV2.get")(function* (name) {
return state.get().commands.get(name)
diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts
index 36534b0d382..ffc268a0e24 100644
--- a/packages/core/src/config/plugin/agent.ts
+++ b/packages/core/src/config/plugin/agent.ts
@@ -1,5 +1,6 @@
export * as ConfigAgentPlugin from "./agent"
+import { define } from "@opencode-ai/plugin/v2/effect"
import path from "path"
import { Effect, Option, Schema } from "effect"
import { AgentV2 } from "../../agent"
@@ -8,7 +9,6 @@ import { ConfigAgent } from "../agent"
import { ConfigMarkdown } from "../markdown"
import { FSUtil } from "../../fs-util"
import { ModelV2 } from "../../model"
-import { PluginV2 } from "../../plugin"
import { ConfigAgentV1 } from "../../v1/config/agent"
import { ConfigMigrateV1 } from "../../v1/config/migrate"
@@ -33,70 +33,70 @@ const agentKeys = new Set([
"permissions",
])
-export const Plugin = PluginV2.define({
- id: PluginV2.ID.make("config-agent"),
- effect: Effect.gen(function* () {
- const agent = yield* AgentV2.Service
+export const Plugin = define({
+ id: "config-agent",
+ effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
- const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
- if (entry.type === "document") return Effect.succeed([entry])
- return Effect.gen(function* () {
- const files = yield* discover(fs, entry.path)
- return yield* Effect.forEach(files, (file) =>
- fs.readFileStringSafe(file.filepath).pipe(
- Effect.map((content) => content && decode(file, content)),
- Effect.catch(() => Effect.succeed(undefined)),
- ),
- ).pipe(
- Effect.map((documents) =>
- documents.filter((document): document is Config.Document => document !== undefined),
- ),
- )
- })
- }).pipe(Effect.map((documents) => documents.flat()))
-
- yield* agent.update((editor) => {
- const global = documents.flatMap((document) => document.info.permissions ?? [])
- const configuredDefault = Config.latest(documents, "default_agent")
- if (configuredDefault !== undefined) editor.default(AgentV2.ID.make(configuredDefault))
- for (const current of editor.list()) {
- editor.update(current.id, (agent) => agent.permissions.push(...global))
- }
-
- for (const document of documents) {
- for (const [id, item] of Object.entries(document.info.agents ?? {})) {
- const agentID = AgentV2.ID.make(id)
- if (item.disabled) {
- editor.remove(agentID)
- continue
- }
-
- const exists = editor.get(agentID) !== undefined
- editor.update(agentID, (agent) => {
- if (!exists) agent.permissions.push(...global)
- if (item.model !== undefined) {
- const model = ModelV2.parse(item.model)
- agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
- }
- if (item.variant !== undefined && agent.model !== undefined) {
- agent.model.variant = ModelV2.VariantID.make(item.variant)
- }
- if (item.request !== undefined) {
- Object.assign(agent.request.headers, item.request.headers ?? {})
- Object.assign(agent.request.body, item.request.body ?? {})
- }
- if (item.system !== undefined) agent.system = item.system
- if (item.description !== undefined) agent.description = item.description
- if (item.mode !== undefined) agent.mode = item.mode
- if (item.hidden !== undefined) agent.hidden = item.hidden
- if (item.color !== undefined) agent.color = item.color
- if (item.steps !== undefined) agent.steps = item.steps
- if (item.permissions !== undefined) agent.permissions.push(...item.permissions)
+ yield* ctx.agent.transform(
+ Effect.fn(function* (draft) {
+ const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
+ if (entry.type === "document") return Effect.succeed([entry])
+ return Effect.gen(function* () {
+ const files = yield* discover(fs, entry.path)
+ return yield* Effect.forEach(files, (file) =>
+ fs.readFileStringSafe(file.filepath).pipe(
+ Effect.map((content) => content && decode(file, content)),
+ Effect.catch(() => Effect.succeed(undefined)),
+ ),
+ ).pipe(
+ Effect.map((documents) =>
+ documents.filter((document): document is Config.Document => document !== undefined),
+ ),
+ )
})
+ }).pipe(Effect.map((documents) => documents.flat()))
+ const global = documents.flatMap((document) => document.info.permissions ?? [])
+ const configuredDefault = Config.latest(documents, "default_agent")
+ if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault))
+ for (const current of draft.list()) {
+ draft.update(current.id, (agent) => agent.permissions.push(...global))
}
- }
- })
+
+ for (const document of documents) {
+ for (const [id, item] of Object.entries(document.info.agents ?? {})) {
+ const agentID = AgentV2.ID.make(id)
+ if (item.disabled) {
+ draft.remove(agentID)
+ continue
+ }
+
+ const exists = draft.get(agentID) !== undefined
+ draft.update(agentID, (agent) => {
+ if (!exists) agent.permissions.push(...global)
+ if (item.model !== undefined) {
+ const model = ModelV2.parse(item.model)
+ agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
+ }
+ if (item.variant !== undefined && agent.model !== undefined) {
+ agent.model.variant = ModelV2.VariantID.make(item.variant)
+ }
+ if (item.request !== undefined) {
+ Object.assign(agent.request.headers, item.request.headers ?? {})
+ Object.assign(agent.request.body, item.request.body ?? {})
+ }
+ if (item.system !== undefined) agent.system = item.system
+ if (item.description !== undefined) agent.description = item.description
+ if (item.mode !== undefined) agent.mode = item.mode
+ if (item.hidden !== undefined) agent.hidden = item.hidden
+ if (item.color !== undefined) agent.color = item.color
+ if (item.steps !== undefined) agent.steps = item.steps
+ if (item.permissions !== undefined) agent.permissions.push(...item.permissions)
+ })
+ }
+ }
+ }),
+ )
}),
})
diff --git a/packages/core/src/config/plugin/command.ts b/packages/core/src/config/plugin/command.ts
index 7e71f306e89..a88c60559e9 100644
--- a/packages/core/src/config/plugin/command.ts
+++ b/packages/core/src/config/plugin/command.ts
@@ -1,52 +1,51 @@
export * as ConfigCommandPlugin from "./command"
+import { define } from "@opencode-ai/plugin/v2/effect"
import path from "path"
import { Effect, Option, Schema } from "effect"
import { CommandV2 } from "../../command"
import { Config } from "../../config"
import { FSUtil } from "../../fs-util"
import { ModelV2 } from "../../model"
-import { PluginV2 } from "../../plugin"
import { ConfigCommand } from "../command"
import { ConfigMarkdown } from "../markdown"
const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info)
-export const Plugin = PluginV2.define({
- id: PluginV2.ID.make("config-command"),
- effect: Effect.gen(function* () {
- const command = yield* CommandV2.Service
+export const Plugin = define({
+ id: "config-command",
+ effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
- const transform = yield* command.transform()
- const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
- if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
- return loadDirectory(fs, entry.path).pipe(
- Effect.map((commands) => [
- { commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
- ]),
- )
- }).pipe(Effect.map((documents) => documents.flat()))
-
- yield* transform((editor) => {
- for (const document of documents) {
- for (const [name, command] of Object.entries(document.commands ?? {})) {
- editor.update(name, (item) => {
- item.template = command.template
- if (command.description !== undefined) item.description = command.description
- if (command.agent !== undefined) item.agent = command.agent
- if (command.model !== undefined) {
- const model = ModelV2.parse(command.model)
- item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
- }
- if (command.variant !== undefined && item.model !== undefined) {
- item.model.variant = ModelV2.VariantID.make(command.variant)
- }
- if (command.subtask !== undefined) item.subtask = command.subtask
- })
+ yield* ctx.command.transform(
+ Effect.fn(function* (draft) {
+ const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
+ if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
+ return loadDirectory(fs, entry.path).pipe(
+ Effect.map((commands) => [
+ { commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
+ ]),
+ )
+ }).pipe(Effect.map((documents) => documents.flat()))
+ for (const document of documents) {
+ for (const [name, command] of Object.entries(document.commands ?? {})) {
+ draft.update(name, (item) => {
+ item.template = command.template
+ if (command.description !== undefined) item.description = command.description
+ if (command.agent !== undefined) item.agent = command.agent
+ if (command.model !== undefined) {
+ const model = ModelV2.parse(command.model)
+ item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
+ }
+ if (command.variant !== undefined && item.model !== undefined) {
+ item.model.variant = ModelV2.VariantID.make(command.variant)
+ }
+ if (command.subtask !== undefined) item.subtask = command.subtask
+ })
+ }
}
- }
- })
+ }),
+ )
}),
})
diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts
index 47a3712e3ad..0171fee37bb 100644
--- a/packages/core/src/config/plugin/provider.ts
+++ b/packages/core/src/config/plugin/provider.ts
@@ -1,123 +1,124 @@
export * as ConfigProviderPlugin from "./provider"
+import { define } from "@opencode-ai/plugin/v2/effect"
import { Effect } from "effect"
-import { Catalog } from "../../catalog"
import { Config } from "../../config"
-import { Integration } from "../../integration"
import { ModelV2 } from "../../model"
import { ModelRequest } from "../../model-request"
-import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
-export const Plugin = PluginV2.define({
- id: PluginV2.ID.make("config-provider"),
- effect: Effect.gen(function* () {
- const catalog = yield* Catalog.Service
+export const Plugin = define({
+ id: "config-provider",
+ effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
- const integrations = yield* Integration.Service
- const transform = yield* catalog.transform()
- const integrationTransform = yield* integrations.transform()
- const entries = yield* config.entries()
- const files = entries.filter((entry): entry is Config.Document => entry.type === "document")
- const configuredIntegrations = new Set(
- files.flatMap((file) =>
- Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])),
- ),
- )
- yield* integrationTransform((integrations) => {
- for (const file of files) {
- for (const [id, item] of Object.entries(file.info.providers ?? {})) {
- const integrationID = Integration.ID.make(id)
- if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
- integrations.update(integrationID, (integration) => {
- integration.name = item.name ?? integration.name
- })
- if (item.env !== undefined) {
- integrations.method.update({
- integrationID,
- method: { type: "env", names: [...item.env] },
+ yield* ctx.integration.transform(
+ Effect.fn(function* (integrations) {
+ const files = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document")
+ const configuredIntegrations = new Set(
+ files.flatMap((file) =>
+ Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) =>
+ provider.env === undefined ? [] : [id],
+ ),
+ ),
+ )
+ for (const file of files) {
+ for (const [id, item] of Object.entries(file.info.providers ?? {})) {
+ const integrationID = id
+ if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
+ integrations.update(integrationID, (integration) => {
+ integration.name = item.name ?? integration.name
})
- }
- }
- }
- })
-
- yield* transform((catalog) => {
- const configuredDefault = Config.latest(entries, "model")
- if (configuredDefault !== undefined) {
- const model = ModelV2.parse(configuredDefault)
- catalog.model.default.set(model.providerID, model.modelID)
- }
- for (const file of files) {
- for (const [id, item] of Object.entries(file.info.providers ?? {})) {
- const providerID = ProviderV2.ID.make(id)
- catalog.provider.update(providerID, (provider) => {
- if (item.name !== undefined) provider.name = item.name
- if (item.api !== undefined) provider.api = { ...item.api }
- if (item.request !== undefined) {
- Object.assign(provider.request.headers, item.request.headers)
- Object.assign(provider.request.body, item.request.body)
+ if (item.env !== undefined) {
+ integrations.method.update({
+ integrationID,
+ method: { type: "env", names: [...item.env] },
+ })
}
- })
- const providerApi = catalog.provider.get(providerID)?.provider.api
- const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined
-
- for (const [id, config] of Object.entries(item.models ?? {})) {
- catalog.model.update(providerID, ModelV2.ID.make(id), (model) => {
- if (config.family !== undefined) model.family = config.family
- if (config.name !== undefined) model.name = config.name
- if (config.api !== undefined) model.api = { ...model.api, ...config.api }
- const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage
- if (config.capabilities !== undefined) {
- model.capabilities = {
- tools: config.capabilities.tools,
- input: [...config.capabilities.input],
- output: [...config.capabilities.output],
- }
- }
- if (config.request !== undefined) {
- ModelRequest.assign(model.request, {
- headers: config.request.headers,
- ...ModelRequest.normalizeAiSdkOptions(packageName, config.request.body ?? {}),
- })
- if (config.request.variant !== undefined) model.request.variant = config.request.variant
- }
- if (config.variants !== undefined) {
- for (const variant of config.variants) {
- let existing = model.variants.find((item) => item.id === variant.id)
- if (!existing) {
- existing = {
- id: variant.id,
- headers: {},
- body: {},
- generation: {},
- options: {},
- }
- model.variants.push(existing)
- }
- ModelRequest.assign(existing, {
- headers: variant.headers,
- ...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}),
- })
- }
- }
- if (config.cost !== undefined) {
- model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
- tier: cost.tier && { ...cost.tier },
- input: cost.input,
- output: cost.output,
- cache: {
- read: cost.cache?.read ?? 0,
- write: cost.cache?.write ?? 0,
- },
- }))
- }
- if (config.disabled !== undefined) model.enabled = !config.disabled
- if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
- })
}
}
- }
- })
+ }),
+ )
+
+ yield* ctx.catalog.transform(
+ Effect.fn(function* (catalog) {
+ const entries = yield* config.entries()
+ const files = entries.filter((entry): entry is Config.Document => entry.type === "document")
+ const configuredDefault = Config.latest(entries, "model")
+ if (configuredDefault !== undefined) {
+ const model = ModelV2.parse(configuredDefault)
+ catalog.model.default.set(model.providerID, model.modelID)
+ }
+ for (const file of files) {
+ for (const [id, item] of Object.entries(file.info.providers ?? {})) {
+ const providerID = id
+ catalog.provider.update(providerID, (provider) => {
+ if (item.name !== undefined) provider.name = item.name
+ if (item.api !== undefined) provider.api = { ...item.api }
+ if (item.request !== undefined) {
+ Object.assign(provider.request.headers, item.request.headers)
+ Object.assign(provider.request.body, item.request.body)
+ }
+ })
+ const providerApi = catalog.provider.get(providerID)?.provider.api
+ const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined
+
+ for (const [id, config] of Object.entries(item.models ?? {})) {
+ catalog.model.update(providerID, id, (model) => {
+ if (config.family !== undefined) model.family = config.family
+ if (config.name !== undefined) model.name = config.name
+ if (config.api !== undefined) model.api = { ...model.api, ...config.api }
+ const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage
+ if (config.capabilities !== undefined) {
+ model.capabilities = {
+ tools: config.capabilities.tools,
+ input: [...config.capabilities.input],
+ output: [...config.capabilities.output],
+ }
+ }
+ if (config.request !== undefined) {
+ ModelRequest.assign(model.request, {
+ headers: config.request.headers,
+ ...ModelRequest.normalizeAiSdkOptions(packageName, config.request.body ?? {}),
+ })
+ if (config.request.variant !== undefined) model.request.variant = config.request.variant
+ }
+ if (config.variants !== undefined) {
+ for (const variant of config.variants) {
+ let existing = model.variants.find((item) => item.id === variant.id)
+ if (!existing) {
+ existing = {
+ id: variant.id,
+ headers: {},
+ body: {},
+ generation: {},
+ options: {},
+ }
+ model.variants.push(existing)
+ }
+ ModelRequest.assign(existing, {
+ headers: variant.headers,
+ ...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}),
+ })
+ }
+ }
+ if (config.cost !== undefined) {
+ model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
+ tier: cost.tier && { ...cost.tier },
+ input: cost.input,
+ output: cost.output,
+ cache: {
+ read: cost.cache?.read ?? 0,
+ write: cost.cache?.write ?? 0,
+ },
+ }))
+ }
+ if (config.disabled !== undefined) model.enabled = !config.disabled
+ if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
+ })
+ }
+ }
+ }
+ }),
+ )
}),
})
diff --git a/packages/core/src/config/plugin/reference.ts b/packages/core/src/config/plugin/reference.ts
index 22c7664996d..f511736e11f 100644
--- a/packages/core/src/config/plugin/reference.ts
+++ b/packages/core/src/config/plugin/reference.ts
@@ -1,57 +1,52 @@
export * as ConfigReferencePlugin from "./reference"
+import { define } from "@opencode-ai/plugin/v2/effect"
import path from "path"
import { Effect } from "effect"
import { Config } from "../../config"
import { ConfigReference } from "../reference"
-import { Global } from "../../global"
-import { Location } from "../../location"
-import { PluginV2 } from "../../plugin"
import { Reference } from "../../reference"
import { AbsolutePath } from "../../schema"
-export const Plugin = {
- id: PluginV2.ID.make("core/config-reference"),
- effect: Effect.gen(function* () {
+export const Plugin = define({
+ id: "core/config-reference",
+ effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
- const global = yield* Global.Service
- const location = yield* Location.Service
- const references = yield* Reference.Service
- const update = yield* references.transform()
- const entries = new Map()
- for (const doc of (yield* config.entries()).filter(
- (entry): entry is Config.Document => entry.type === "document",
- )) {
- const directory = doc.path ? path.dirname(doc.path) : location.directory
- for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
- if (!validAlias(name)) continue
- entries.set(
- name,
- local(entry)
- ? new Reference.LocalSource({
- type: "local",
- path: AbsolutePath.make(
- localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
- ),
- description: typeof entry === "string" ? undefined : entry.description,
- hidden: typeof entry === "string" ? undefined : entry.hidden,
- })
- : new Reference.GitSource({
- type: "git",
- repository: typeof entry === "string" ? entry : entry.repository,
- branch: typeof entry === "string" ? undefined : entry.branch,
- description: typeof entry === "string" ? undefined : entry.description,
- hidden: typeof entry === "string" ? undefined : entry.hidden,
- }),
- )
- }
- }
-
- yield* update((editor) => {
- for (const [name, source] of entries) editor.add(name, source)
- })
+ yield* ctx.reference.transform(
+ Effect.fn(function* (draft) {
+ const entries = new Map()
+ for (const doc of (yield* config.entries()).filter(
+ (entry): entry is Config.Document => entry.type === "document",
+ )) {
+ const directory = doc.path ? path.dirname(doc.path) : ctx.location.directory
+ for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
+ if (!validAlias(name)) continue
+ entries.set(
+ name,
+ local(entry)
+ ? new Reference.LocalSource({
+ type: "local",
+ path: AbsolutePath.make(
+ localPath(directory, ctx.path.home, typeof entry === "string" ? entry : entry.path),
+ ),
+ description: typeof entry === "string" ? undefined : entry.description,
+ hidden: typeof entry === "string" ? undefined : entry.hidden,
+ })
+ : new Reference.GitSource({
+ type: "git",
+ repository: typeof entry === "string" ? entry : entry.repository,
+ branch: typeof entry === "string" ? undefined : entry.branch,
+ description: typeof entry === "string" ? undefined : entry.description,
+ hidden: typeof entry === "string" ? undefined : entry.hidden,
+ }),
+ )
+ }
+ }
+ for (const [name, source] of entries) draft.add(name, source)
+ }),
+ )
}),
-}
+})
function validAlias(name: string) {
return name.length > 0 && !/[\/\s`,]/.test(name)
diff --git a/packages/core/src/config/plugin/skill.ts b/packages/core/src/config/plugin/skill.ts
index 30b7a882766..9f6a99d8b1a 100644
--- a/packages/core/src/config/plugin/skill.ts
+++ b/packages/core/src/config/plugin/skill.ts
@@ -1,48 +1,45 @@
export * as ConfigSkillPlugin from "./skill"
+import { define } from "@opencode-ai/plugin/v2/effect"
import path from "path"
import { Effect } from "effect"
import { Config } from "../../config"
-import { Global } from "../../global"
-import { Location } from "../../location"
-import { PluginV2 } from "../../plugin"
import { AbsolutePath } from "../../schema"
import { SkillV2 } from "../../skill"
-export const Plugin = PluginV2.define({
- id: PluginV2.ID.make("config-skill"),
- effect: Effect.gen(function* () {
+export const Plugin = define({
+ id: "config-skill",
+ effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
- const global = yield* Global.Service
- const location = yield* Location.Service
- const skill = yield* SkillV2.Service
- const transform = yield* skill.transform()
- const entries = yield* config.entries()
- const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
- const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
-
- yield* transform((editor) => {
- for (const directory of directories) {
- editor.source(
- new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
- )
- editor.source(
- new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
- )
- }
- for (const item of items) {
- if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
- editor.source(new SkillV2.UrlSource({ type: "url", url: item }))
- continue
+ yield* ctx.skill.transform(
+ Effect.fn(function* (draft) {
+ const entries = yield* config.entries()
+ const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
+ const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
+ for (const directory of directories) {
+ draft.source(
+ new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
+ )
+ draft.source(
+ new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
+ )
}
- const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
- editor.source(
- new SkillV2.DirectorySource({
- type: "directory",
- path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
- }),
- )
- }
- })
+ for (const item of items) {
+ if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
+ draft.source(new SkillV2.UrlSource({ type: "url", url: item }))
+ continue
+ }
+ const expanded = item.startsWith("~/") ? path.join(ctx.path.home, item.slice(2)) : item
+ draft.source(
+ new SkillV2.DirectorySource({
+ type: "directory",
+ path: AbsolutePath.make(
+ path.isAbsolute(expanded) ? expanded : path.join(ctx.location.directory, expanded),
+ ),
+ }),
+ )
+ }
+ }),
+ )
}),
})
diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts
index 90995b1987c..4bd27ffd3c9 100644
--- a/packages/core/src/integration.ts
+++ b/packages/core/src/integration.ts
@@ -1,7 +1,19 @@
export * as Integration from "./integration"
-import { Cause, Clock, Context, Duration, Effect, Exit, Layer, Schedule, Schema, Scope, SynchronizedRef } from "effect"
-import { castDraft, enableMapSet, type Draft } from "immer"
+import {
+ Cause,
+ Clock,
+ Context,
+ Duration,
+ Effect,
+ Exit,
+ Layer,
+ Schedule,
+ Schema,
+ Scope,
+ SynchronizedRef,
+ Types,
+} from "effect"
import { Credential } from "./credential"
import { IntegrationSchema } from "./integration/schema"
import { withStatics } from "./schema"
@@ -42,12 +54,14 @@ export const SelectPrompt = Schema.Struct({
type: Schema.Literal("select"),
key: Schema.String,
message: Schema.String,
- options: Schema.Array(
- Schema.Struct({
- label: Schema.String,
- value: Schema.String,
- hint: Schema.optional(Schema.String),
- }),
+ options: Schema.mutable(
+ Schema.Array(
+ Schema.Struct({
+ label: Schema.String,
+ value: Schema.String,
+ hint: Schema.optional(Schema.String),
+ }),
+ ),
),
when: Schema.optional(When),
}).annotate({ identifier: "Integration.SelectPrompt" })
@@ -60,7 +74,7 @@ export const OAuthMethod = Schema.Struct({
id: MethodID,
type: Schema.Literal("oauth"),
label: Schema.String,
- prompts: Schema.optional(Schema.Array(Prompt)),
+ prompts: Schema.optional(Schema.mutable(Schema.Array(Prompt))),
}).annotate({ identifier: "Integration.OAuthMethod" })
export type OAuthMethod = typeof OAuthMethod.Type
@@ -72,7 +86,7 @@ export type KeyMethod = typeof KeyMethod.Type
export const EnvMethod = Schema.Struct({
type: Schema.Literal("env"),
- names: Schema.Array(Schema.String),
+ names: Schema.mutable(Schema.Array(Schema.String)),
}).annotate({ identifier: "Integration.EnvMethod" })
export type EnvMethod = typeof EnvMethod.Type
@@ -82,8 +96,8 @@ export type Method = typeof Method.Type
export class Info extends Schema.Class("Integration.Info")({
id: ID,
name: Schema.String,
- methods: Schema.Array(Method),
- connections: Schema.Array(IntegrationConnection.Info),
+ methods: Schema.mutable(Schema.Array(Method)),
+ connections: Schema.mutable(Schema.Array(IntegrationConnection.Info)),
}) {}
export type Inputs = Readonly<{ [key: string]: string }>
@@ -172,19 +186,19 @@ export type Ref = {
}
type Entry = {
- ref: Ref
- methods: Method[]
- implementations: Map
+ ref: Types.DeepMutable[
+ methods: Types.DeepMutable[]
+ implementations: Map>
}
type Data = {
integrations: Map
}
-export type Editor = {
+export type Draft = {
list: () => readonly Ref[]
get: (id: ID) => Ref | undefined
- update: (id: ID, update: (integration: Draft][) => void) => void
+ update: (id: ID, update: (integration: Types.DeepMutable][) => void) => void
remove: (id: ID) => void
method: {
list: (integrationID: ID) => readonly Method[]
@@ -193,11 +207,8 @@ export type Editor = {
}
}
-export interface Interface {
+export interface Interface extends State.Transformable {
/** Registers a scoped transform over the integration registry. */
- readonly transform: State.Interface["transform"]
- /** Registers and immediately applies a scoped integration registry update. */
- readonly update: State.Interface["update"]
/** Returns one integration with its methods and current connections. */
readonly get: (id: ID) => Effect.Effect
/** Returns all integrations with their methods and current connections. */
@@ -252,8 +263,6 @@ export interface Interface {
export class Service extends Context.Service()("@opencode/v2/Integration") {}
-enableMapSet()
-
const attemptLifetime = Duration.toMillis(Duration.minutes(10))
const terminalRetention = Duration.toMillis(Duration.minutes(1))
const scrubInterval = Duration.seconds(30)
@@ -284,15 +293,17 @@ export const locationLayer = Layer.effect(
const events = yield* EventV2.Service
const scope = yield* Scope.Scope
const attempts = SynchronizedRef.makeUnsafe(new Map())
- const state = State.create({
+ const state = State.create({
initial: () => ({ integrations: new Map() }),
- editor: (draft) => ({
+ draft: (draft) => ({
list: () => Array.from(draft.integrations.values(), (entry) => entry.ref) as Ref[],
get: (id) => draft.integrations.get(id)?.ref as Ref | undefined,
update: (id, update) => {
- const current =
- draft.integrations.get(id) ??
- castDraft({ ref: { id, name: id } as Ref, methods: [], implementations: new Map() })
+ const current = draft.integrations.get(id) ?? {
+ ref: { id, name: id },
+ methods: [],
+ implementations: new Map(),
+ }
if (!draft.integrations.has(id)) draft.integrations.set(id, current)
update(current.ref)
current.ref.id = id
@@ -301,16 +312,14 @@ export const locationLayer = Layer.effect(
method: {
list: (integrationID) => (draft.integrations.get(integrationID)?.methods as Method[] | undefined) ?? [],
update: (implementation) => {
- const current =
- draft.integrations.get(implementation.integrationID) ??
- castDraft({
- ref: {
- id: implementation.integrationID,
- name: implementation.integrationID,
- } as Ref,
- methods: [],
- implementations: new Map(),
- })
+ const current = draft.integrations.get(implementation.integrationID) ?? {
+ ref: {
+ id: implementation.integrationID,
+ name: implementation.integrationID,
+ },
+ methods: [],
+ implementations: new Map>(),
+ }
if (!draft.integrations.has(implementation.integrationID)) {
draft.integrations.set(implementation.integrationID, current)
}
@@ -319,10 +328,13 @@ export const locationLayer = Layer.effect(
if (method.type !== "oauth" || implementation.method.type !== "oauth") return true
return method.id === implementation.method.id
})
- if (index === -1) current.methods.push(castDraft(implementation.method))
- else current.methods[index] = castDraft(implementation.method)
- if (isOAuthImplementation(implementation)) {
- current.implementations.set(implementation.method.id, castDraft(implementation))
+ if (index === -1) current.methods.push(implementation.method as Types.DeepMutable)
+ else current.methods[index] = implementation.method as Types.DeepMutable
+ if (implementation.method.type === "oauth") {
+ current.implementations.set(
+ implementation.method.id,
+ implementation as Types.DeepMutable,
+ )
}
},
remove: (integrationID, method) => {
@@ -434,7 +446,7 @@ export const locationLayer = Layer.effect(
return Service.of({
transform: state.transform,
- update: state.update,
+ rebuild: state.rebuild,
get: Effect.fn("Integration.get")(function* (id) {
const entry = state.get().integrations.get(id)
if (!entry) return undefined
diff --git a/packages/core/src/model-request.ts b/packages/core/src/model-request.ts
index f9f4f56936d..5de1f9803dc 100644
--- a/packages/core/src/model-request.ts
+++ b/packages/core/src/model-request.ts
@@ -33,7 +33,7 @@ export type Request = typeof Request.Type
interface MutableRequest {
headers: Record
body: Record
- generation?: Generation
+ generation?: Record
options?: Record
}
diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts
index 3b0beece55f..8a06229e040 100644
--- a/packages/core/src/model.ts
+++ b/packages/core/src/model.ts
@@ -1,5 +1,4 @@
-import { DateTime, Schema } from "effect"
-import { DateTimeUtcFromMillis } from "effect/Schema"
+import { Schema, Types } from "effect"
import { ProviderV2 } from "./provider"
import { ModelRequest } from "./model-request"
@@ -16,8 +15,8 @@ export type Family = typeof Family.Type
export const Capabilities = Schema.Struct({
tools: Schema.Boolean,
// mime patterns, image, audio, video/*, text/*
- input: Schema.String.pipe(Schema.Array),
- output: Schema.String.pipe(Schema.Array),
+ input: Schema.String.pipe(Schema.Array, Schema.mutable),
+ output: Schema.String.pipe(Schema.Array, Schema.mutable),
})
export type Capabilities = typeof Capabilities.Type
@@ -67,11 +66,11 @@ export class Info extends Schema.Class("ModelV2.Info")({
variants: Schema.Struct({
id: VariantID,
...ModelRequest.Request.fields,
- }).pipe(Schema.Array),
+ }).pipe(Schema.Array, Schema.mutable),
time: Schema.Struct({
- released: DateTimeUtcFromMillis,
+ released: Schema.Finite,
}),
- cost: Cost.pipe(Schema.Array),
+ cost: Cost.pipe(Schema.Array, Schema.mutable),
status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
enabled: Schema.Boolean,
limit: Schema.Struct({
@@ -103,7 +102,7 @@ export class Info extends Schema.Class("ModelV2.Info")({
},
variants: [],
time: {
- released: DateTime.makeUnsafe(0),
+ released: 0,
},
cost: [],
status: "active",
@@ -116,6 +115,10 @@ export class Info extends Schema.Class("ModelV2.Info")({
}
}
+export type MutableInfo = Omit, "api"> & {
+ api: ProviderV2.MutableApi
+}
+
export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } {
const [providerID, ...modelID] = input.split("/")
return {
diff --git a/packages/core/src/npm.ts b/packages/core/src/npm.ts
index 48ad74c1807..3ad8beb0a77 100644
--- a/packages/core/src/npm.ts
+++ b/packages/core/src/npm.ts
@@ -20,7 +20,7 @@ export class InstallFailedError extends Schema.TaggedErrorClass
+ readonly entrypoint?: string
}
export interface Interface {
@@ -34,7 +34,7 @@ export interface Interface {
}[]
},
) => Effect.Effect
- readonly which: (pkg: string, bin?: string) => Effect.Effect>
+ readonly which: (pkg: string, bin?: string) => Effect.Effect
}
export class Service extends Context.Service()("@opencode/Npm") {}
@@ -47,12 +47,11 @@ export function sanitize(pkg: string) {
}
const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
- let entrypoint: Option.Option
+ let entrypoint: string | undefined
try {
- const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
- entrypoint = Option.some(resolved)
+ entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
} catch {
- entrypoint = Option.none()
+ entrypoint = undefined
}
return {
directory: dir,
@@ -130,7 +129,7 @@ export const layer = Layer.effect(
const first = tree.edgesOut.values().next().value?.to
if (!first) {
const result = resolveEntryPoint(name, path.join(dir, "node_modules", name))
- if (Option.isSome(result.entrypoint)) return result
+ if (result.entrypoint) return result
return yield* new InstallFailedError({ add: [pkg], dir })
}
return resolveEntryPoint(first.name, first.path)
@@ -219,22 +218,24 @@ export const layer = Layer.effect(
return Option.some(files[0])
})
- return yield* Effect.gen(function* () {
- const bin = yield* pick()
- if (Option.isSome(bin)) {
- return Option.some(path.join(binDir, bin.value))
- }
+ return Option.getOrUndefined(
+ yield* Effect.gen(function* () {
+ const bin = yield* pick()
+ if (Option.isSome(bin)) {
+ return Option.some(path.join(binDir, bin.value))
+ }
- yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))
+ yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))
- yield* add(pkg)
+ yield* add(pkg)
- const resolved = yield* pick()
- if (Option.isNone(resolved)) return Option.none()
- return Option.some(path.join(binDir, resolved.value))
- }).pipe(
- Effect.scoped,
- Effect.orElseSucceed(() => Option.none()),
+ const resolved = yield* pick()
+ if (Option.isNone(resolved)) return Option.none()
+ return Option.some(path.join(binDir, resolved.value))
+ }).pipe(
+ Effect.scoped,
+ Effect.orElseSucceed(() => Option.none()),
+ ),
)
})
@@ -261,14 +262,9 @@ export async function install(...args: Parameters) {
}
export async function add(...args: Parameters) {
- const entry = await runPromise((svc) => svc.add(...args))
- return {
- directory: entry.directory,
- entrypoint: Option.getOrUndefined(entry.entrypoint),
- }
+ return runPromise((svc) => svc.add(...args))
}
export async function which(...args: Parameters) {
- const resolved = await runPromise((svc) => svc.which(...args))
- return Option.getOrUndefined(resolved)
+ return runPromise((svc) => svc.which(...args))
}
diff --git a/packages/core/src/permission/schema.ts b/packages/core/src/permission/schema.ts
index 2d806dbd8c5..9fde8ef8ec1 100644
--- a/packages/core/src/permission/schema.ts
+++ b/packages/core/src/permission/schema.ts
@@ -12,5 +12,5 @@ export const Rule = Schema.Struct({
}).annotate({ identifier: "PermissionV2.Rule" })
export type Rule = typeof Rule.Type
-export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionV2.Ruleset" })
+export const Ruleset = Schema.mutable(Schema.Array(Rule)).annotate({ identifier: "PermissionV2.Ruleset" })
export type Ruleset = typeof Ruleset.Type
diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts
index aaef65d3227..c826ba513bf 100644
--- a/packages/core/src/plugin.ts
+++ b/packages/core/src/plugin.ts
@@ -7,6 +7,7 @@ import type { ModelV2 } from "./model"
import type { Catalog } from "./catalog"
import { EventV2 } from "./event"
import { KeyedMutex } from "./effect/keyed-mutex"
+import { State } from "./state"
export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
export type ID = typeof ID.Type
@@ -22,7 +23,7 @@ export const Event = {
type HookSpec = {
"catalog.transform": {
- input: Catalog.Editor
+ input: Catalog.Draft
output: {}
}
"aisdk.language": {
@@ -62,18 +63,16 @@ export type HookFunctions = {
export type HookInput = HookSpec[Name]["input"]
export type HookOutput = HookSpec[Name]["output"]
-export type Effect = Effect.Effect
-
-export function define(input: { id: ID; effect: Effect.Effect }) {
- return input
-}
-
export interface Interface {
readonly add: (input: {
- id: ID
+ id: string
effect: Effect.Effect
}) => Effect.Effect
readonly remove: (id: ID) => Effect.Effect
+ readonly hook: (
+ name: Name,
+ callback: (input: Hooks[Name]) => Effect.Effect | void,
+ ) => Effect.Effect
readonly triggerFor: (
id: ID,
name: Name,
@@ -97,35 +96,40 @@ export const layer = Layer.effect(
hooks: HookFunctions
scope: Scope.Closeable
}[] = []
+ let registrations: {
+ [Name in keyof Hooks]: {
+ name: Name
+ callback: (input: Hooks[Name]) => Effect.Effect | void
+ }
+ }[keyof Hooks][] = []
const events = yield* EventV2.Service
const scope = yield* Scope.Scope
const locks = KeyedMutex.makeUnsafe()
const svc = Service.of({
add: Effect.fn("Plugin.add")(function* (input) {
- yield* locks.withLock(input.id)(
+ const id = ID.make(input.id)
+ yield* locks.withLock(id)(
Effect.gen(function* () {
- const existing = hooks.find((item) => item.id === input.id)
+ const existing = hooks.find((item) => item.id === id)
if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore)
const childScope = yield* Scope.fork(scope)
const result = yield* input.effect.pipe(
Scope.provide(childScope),
Effect.withSpan("Plugin.load", {
attributes: {
- "plugin.id": input.id,
+ "plugin.id": id,
},
}),
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(childScope, exit) : Effect.void)),
)
- hooks = [
- ...hooks.filter((item) => item.id !== input.id),
- {
- id: input.id,
- hooks: result ?? {},
- scope: childScope,
- },
- ]
- yield* events.publish(Event.Added, { id: input.id })
+ const next = {
+ id,
+ hooks: result ?? {},
+ scope: childScope,
+ }
+ hooks = existing ? hooks.with(hooks.indexOf(existing), next) : [...hooks, next]
+ yield* events.publish(Event.Added, { id })
}),
)
}),
@@ -160,6 +164,12 @@ export const layer = Layer.effect(
)
}
+ for (const item of registrations) {
+ if (item.name !== name) continue
+ const result = item.callback(event as never)
+ if (Effect.isEffect(result)) yield* result
+ }
+
for (const [field, draft] of draftEntries) {
event[field] = finishDraft(draft)
}
@@ -175,6 +185,19 @@ export const layer = Layer.effect(
}),
)
}),
+ hook: Effect.fn("Plugin.hook")(function* (name, callback) {
+ const scope = yield* Scope.Scope
+ const registration = { name, callback } as (typeof registrations)[number]
+ let active = true
+ registrations = [...registrations, registration]
+ const dispose = Effect.sync(() => {
+ if (!active) return
+ active = false
+ registrations = registrations.filter((item) => item !== registration)
+ })
+ yield* Scope.addFinalizer(scope, dispose)
+ return { dispose }
+ }),
})
return svc
}),
diff --git a/packages/core/src/plugin/agent.ts b/packages/core/src/plugin/agent.ts
index e8a8d8bc9d6..735ddd31072 100644
--- a/packages/core/src/plugin/agent.ts
+++ b/packages/core/src/plugin/agent.ts
@@ -1,12 +1,11 @@
export * as AgentPlugin from "./agent"
import path from "path"
+import { define } from "@opencode-ai/plugin/v2/effect"
import { Effect } from "effect"
import { AgentV2 } from "../agent"
import { Global } from "../global"
-import { Location } from "../location"
import { PermissionV2 } from "../permission"
-import { PluginV2 } from "../plugin"
const TRUNCATION_GLOB = path.join(Global.Path.data, "tool-output", "*")
const BUILD_SYSTEM =
@@ -97,12 +96,10 @@ Rules:
- If the conversation ends with an unanswered question to the user, preserve that exact question
- If the conversation ends with an imperative statement or request to the user (e.g. "Now please run the command and paste the console output"), always include that exact request in the summary`
-export const Plugin = PluginV2.define({
- id: PluginV2.ID.make("agent"),
- effect: Effect.gen(function* () {
- const agent = yield* AgentV2.Service
- const location = yield* Location.Service
- const worktree = location.directory
+export const Plugin = define({
+ id: "agent",
+ effect: Effect.fn(function* (ctx) {
+ const worktree = ctx.location.directory
const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")]
const readonlyExternalDirectory: PermissionV2.Ruleset = [
{ action: "external_directory", resource: "*", effect: "ask" },
@@ -122,8 +119,8 @@ export const Plugin = PluginV2.define({
{ action: "read", resource: "*.env.example", effect: "allow" },
]
- yield* agent.update((editor) => {
- editor.update(AgentV2.defaultID, (item) => {
+ yield* ctx.agent.transform((draft) => {
+ draft.update(AgentV2.defaultID, (item) => {
item.description = "The default agent. Executes tools based on configured permissions."
item.system ??= BUILD_SYSTEM
item.mode = "primary"
@@ -135,7 +132,7 @@ export const Plugin = PluginV2.define({
)
})
- editor.update(AgentV2.ID.make("plan"), (item) => {
+ draft.update(AgentV2.ID.make("plan"), (item) => {
item.description = "Plan mode. Disallows all edit tools."
item.mode = "primary"
item.permissions.push(
@@ -154,14 +151,14 @@ export const Plugin = PluginV2.define({
)
})
- editor.update(AgentV2.ID.make("general"), (item) => {
+ draft.update(AgentV2.ID.make("general"), (item) => {
item.description =
"General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel."
item.mode = "subagent"
item.permissions.push(...PermissionV2.merge(defaults, [{ action: "todowrite", resource: "*", effect: "deny" }]))
})
- editor.update(AgentV2.ID.make("explore"), (item) => {
+ draft.update(AgentV2.ID.make("explore"), (item) => {
item.description =
'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.'
item.system = PROMPT_EXPLORE
@@ -182,21 +179,21 @@ export const Plugin = PluginV2.define({
)
})
- editor.update(AgentV2.ID.make("compaction"), (item) => {
+ draft.update(AgentV2.ID.make("compaction"), (item) => {
item.mode = "primary"
item.hidden = true
item.system = PROMPT_COMPACTION
item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
})
- editor.update(AgentV2.ID.make("title"), (item) => {
+ draft.update(AgentV2.ID.make("title"), (item) => {
item.mode = "primary"
item.hidden = true
item.system = PROMPT_TITLE
item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
})
- editor.update(AgentV2.ID.make("summary"), (item) => {
+ draft.update(AgentV2.ID.make("summary"), (item) => {
item.mode = "primary"
item.hidden = true
item.system = PROMPT_SUMMARY
diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts
index cc7b0c247a9..3acd94a218f 100644
--- a/packages/core/src/plugin/boot.ts
+++ b/packages/core/src/plugin/boot.ts
@@ -1,7 +1,7 @@
export * as PluginBoot from "./boot"
+import type { Plugin as PublicPlugin } from "@opencode-ai/plugin/v2/effect"
import { Context, Deferred, Effect, Layer } from "effect"
-import { Credential } from "../credential"
import { Integration } from "../integration"
import { AgentV2 } from "../agent"
import { Catalog } from "../catalog"
@@ -13,6 +13,7 @@ import { ConfigSkillPlugin } from "../config/plugin/skill"
import { ConfigReferencePlugin } from "../config/plugin/reference"
import { EventV2 } from "../event"
import { FSUtil } from "../fs-util"
+import { FileSystem } from "../filesystem"
import { Global } from "../global"
import { Location } from "../location"
import { ModelsDev } from "../models-dev"
@@ -26,29 +27,13 @@ import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
import { SkillV2 } from "../skill"
import { Reference } from "../reference"
+import { State } from "../state"
+import { PluginHost } from "./host"
-type Plugin = {
- id: PluginV2.ID
- effect: PluginV2.Effect<
- | Catalog.Service
- | CommandV2.Service
- | Credential.Service
- | Integration.Service
- | AgentV2.Service
- | Npm.Service
- | EventV2.Service
- | FSUtil.Service
- | Global.Service
- | Location.Service
- | PluginV2.Service
- | Config.Service
- | ModelsDev.Service
- | SkillV2.Service
- | Reference.Service
- >
-}
+type InternalPlugin = PublicPlugin]