diff --git a/packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx
index 2311be3215..6da5c42ed0 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx
@@ -6,6 +6,14 @@ import { formatDateUTC, formatDateForTable } from "../../common"
import styles from "./payment-section.module.css"
import { useI18n } from "~/context/i18n"
+function money(amount: number, currency?: string) {
+ const formatter =
+ currency === "inr"
+ ? new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR" })
+ : new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
+ return formatter.format(amount / 100_000_000)
+}
+
const getPaymentsInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
@@ -81,6 +89,10 @@ export function PaymentSection() {
const date = new Date(payment.timeCreated)
const amount =
payment.enrichment?.type === "subscription" && payment.enrichment.couponID ? 0 : payment.amount
+ const currency =
+ payment.enrichment?.type === "subscription" || payment.enrichment?.type === "lite"
+ ? payment.enrichment.currency
+ : undefined
return (
@@ -88,7 +100,7 @@ export function PaymentSection() {
{payment.id}
- ${((amount ?? 0) / 100000000).toFixed(2)}
+ {money(amount, currency)}
{" "}
diff --git a/packages/console/app/src/routes/workspace/[id]/go/lite-section.module.css b/packages/console/app/src/routes/workspace/[id]/go/lite-section.module.css
index a760753d04..05daf43b7a 100644
--- a/packages/console/app/src/routes/workspace/[id]/go/lite-section.module.css
+++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.module.css
@@ -188,8 +188,45 @@
line-height: 1.4;
}
- [data-slot="subscribe-button"] {
- align-self: flex-start;
+ [data-slot="subscribe-actions"] {
+ display: flex;
+ align-items: center;
+ gap: var(--space-4);
margin-top: var(--space-4);
}
+
+ [data-slot="subscribe-button"] {
+ align-self: stretch;
+ }
+
+ [data-slot="other-methods"] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+ }
+
+ [data-slot="other-methods-icons"] {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ }
+
+ [data-slot="modal-actions"] {
+ display: flex;
+ gap: var(--space-3);
+ margin-top: var(--space-4);
+
+ button {
+ flex: 1;
+ }
+ }
+
+ [data-slot="method-button"] {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ gap: var(--space-2);
+ height: 48px;
+ }
}
diff --git a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx
index ccdda5b450..2f8ad8aba4 100644
--- a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx
@@ -1,6 +1,7 @@
import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router"
import { createStore } from "solid-js/store"
import { createMemo, For, Show } from "solid-js"
+import { Modal } from "~/component/modal"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable, LiteTable } from "@opencode-ai/console-core/schema/billing.sql.js"
@@ -14,6 +15,8 @@ import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import { formError } from "~/lib/form-error"
+import { IconAlipay, IconUpi } from "~/component/icon"
+
const queryLiteSubscription = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
@@ -78,22 +81,25 @@ function formatResetTime(seconds: number, i18n: ReturnType) {
return `${minutes} ${minutes === 1 ? i18n.t("workspace.lite.time.minute") : i18n.t("workspace.lite.time.minutes")}`
}
-const createLiteCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => {
- "use server"
- return json(
- await withActor(
- () =>
- Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl })
- .then((data) => ({ error: undefined, data }))
- .catch((e) => ({
- error: e.message as string,
- data: undefined,
- })),
- workspaceID,
- ),
- { revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
- )
-}, "liteCheckoutUrl")
+const createLiteCheckoutUrl = action(
+ async (workspaceID: string, successUrl: string, cancelUrl: string, method?: "alipay" | "upi") => {
+ "use server"
+ return json(
+ await withActor(
+ () =>
+ Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl, method })
+ .then((data) => ({ error: undefined, data }))
+ .catch((e) => ({
+ error: e.message as string,
+ data: undefined,
+ })),
+ workspaceID,
+ ),
+ { revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
+ )
+ },
+ "liteCheckoutUrl",
+)
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
"use server"
@@ -147,23 +153,30 @@ export function LiteSection() {
const checkoutSubmission = useSubmission(createLiteCheckoutUrl)
const useBalanceSubmission = useSubmission(setLiteUseBalance)
const [store, setStore] = createStore({
- redirecting: false,
+ loading: undefined as undefined | "session" | "checkout" | "alipay" | "upi",
+ showModal: false,
})
+ const busy = createMemo(() => !!store.loading)
+
async function onClickSession() {
+ setStore("loading", "session")
const result = await sessionAction(params.id!, window.location.href)
if (result.data) {
- setStore("redirecting", true)
window.location.href = result.data
+ return
}
+ setStore("loading", undefined)
}
- async function onClickSubscribe() {
- const result = await checkoutAction(params.id!, window.location.href, window.location.href)
+ async function onClickSubscribe(method?: "alipay" | "upi") {
+ setStore("loading", method ?? "checkout")
+ const result = await checkoutAction(params.id!, window.location.href, window.location.href, method)
if (result.data) {
- setStore("redirecting", true)
window.location.href = result.data
+ return
}
+ setStore("loading", undefined)
}
return (
@@ -179,12 +192,8 @@ export function LiteSection() {
{i18n.t("workspace.lite.subscription.message")}
-
- {sessionSubmission.pending || store.redirecting
+
+ {store.loading === "session"
? i18n.t("workspace.lite.loading")
: i18n.t("workspace.lite.subscription.manage")}
@@ -282,16 +291,64 @@ export function LiteSection() {
MiniMax M2.7
{i18n.t("workspace.lite.promo.footer")}
-
+ onClickSubscribe()}
+ >
+ {store.loading === "checkout"
+ ? i18n.t("workspace.lite.promo.subscribing")
+ : i18n.t("workspace.lite.promo.subscribe")}
+
+ setStore("showModal", true)}
+ >
+ {i18n.t("workspace.lite.promo.otherMethods")}
+
+
+
+
+
+
+
+
+
setStore("showModal", false)}
+ title={i18n.t("workspace.lite.promo.selectMethod")}
>
- {checkoutSubmission.pending || store.redirecting
- ? i18n.t("workspace.lite.promo.subscribing")
- : i18n.t("workspace.lite.promo.subscribe")}
-
+
+ onClickSubscribe("alipay")}
+ >
+
+
+
+ {store.loading === "alipay" ? i18n.t("workspace.lite.promo.subscribing") : "Alipay"}
+
+ onClickSubscribe("upi")}
+ >
+
+
+
+ {store.loading === "upi" ? i18n.t("workspace.lite.promo.subscribing") : "UPI"}
+
+
+
>
diff --git a/packages/console/core/src/billing.ts b/packages/console/core/src/billing.ts
index ee41652ef2..66b9806985 100644
--- a/packages/console/core/src/billing.ts
+++ b/packages/console/core/src/billing.ts
@@ -239,10 +239,11 @@ export namespace Billing {
z.object({
successUrl: z.string(),
cancelUrl: z.string(),
+ method: z.enum(["alipay", "upi"]).optional(),
}),
async (input) => {
const user = Actor.assert("user")
- const { successUrl, cancelUrl } = input
+ const { successUrl, cancelUrl, method } = input
const email = await User.getAuthEmail(user.properties.userID)
const billing = await Billing.get()
@@ -250,38 +251,102 @@ export namespace Billing {
if (billing.subscriptionID) throw new Error("Already subscribed to Black")
if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite")
- const session = await Billing.stripe().checkout.sessions.create({
- mode: "subscription",
- billing_address_collection: "required",
- line_items: [{ price: LiteData.priceID(), quantity: 1 }],
- discounts: [{ coupon: LiteData.firstMonth50Coupon() }],
- ...(billing.customerID
- ? {
- customer: billing.customerID,
- customer_update: {
- name: "auto",
- address: "auto",
- },
+ const createSession = () =>
+ Billing.stripe().checkout.sessions.create({
+ mode: "subscription",
+ discounts: [{ coupon: LiteData.firstMonth50Coupon() }],
+ ...(billing.customerID
+ ? {
+ customer: billing.customerID,
+ customer_update: {
+ name: "auto",
+ address: "auto",
+ },
+ }
+ : {
+ customer_email: email!,
+ }),
+ ...(() => {
+ if (method === "alipay") {
+ return {
+ line_items: [{ price: LiteData.priceID(), quantity: 1 }],
+ payment_method_types: ["alipay"],
+ adaptive_pricing: {
+ enabled: false,
+ },
+ }
}
- : {
- customer_email: email!,
- }),
- currency: "usd",
- tax_id_collection: {
- enabled: true,
- },
- success_url: successUrl,
- cancel_url: cancelUrl,
- subscription_data: {
- metadata: {
- workspaceID: Actor.workspace(),
- userID: user.properties.userID,
- type: "lite",
+ if (method === "upi") {
+ return {
+ line_items: [
+ {
+ price_data: {
+ currency: "inr",
+ product: LiteData.productID(),
+ recurring: {
+ interval: "month",
+ interval_count: 1,
+ },
+ unit_amount: LiteData.priceInr(),
+ },
+ quantity: 1,
+ },
+ ],
+ payment_method_types: ["upi"] as any,
+ adaptive_pricing: {
+ enabled: false,
+ },
+ }
+ }
+ return {
+ line_items: [{ price: LiteData.priceID(), quantity: 1 }],
+ billing_address_collection: "required",
+ }
+ })(),
+ tax_id_collection: {
+ enabled: true,
},
- },
- })
+ success_url: successUrl,
+ cancel_url: cancelUrl,
+ subscription_data: {
+ metadata: {
+ workspaceID: Actor.workspace(),
+ userID: user.properties.userID,
+ type: "lite",
+ },
+ },
+ })
- return session.url
+ try {
+ const session = await createSession()
+ return session.url
+ } catch (e: any) {
+ if (
+ e.type !== "StripeInvalidRequestError" ||
+ !e.message.includes("You cannot combine currencies on a single customer")
+ )
+ throw e
+
+ // get pending payment intent
+ const intents = await Billing.stripe().paymentIntents.search({
+ query: `-status:'canceled' AND -status:'processing' AND -status:'succeeded' AND customer:'${billing.customerID}'`,
+ })
+ if (intents.data.length === 0) throw e
+
+ for (const intent of intents.data) {
+ // get checkout session
+ const sessions = await Billing.stripe().checkout.sessions.list({
+ customer: billing.customerID!,
+ payment_intent: intent.id,
+ })
+
+ // delete pending payment intent
+ await Billing.stripe().checkout.sessions.expire(sessions.data[0].id)
+ }
+
+ const session = await createSession()
+ return session.url
+ }
},
)
diff --git a/packages/console/core/src/lite.ts b/packages/console/core/src/lite.ts
index 8c5b63d0c7..2c4a09f711 100644
--- a/packages/console/core/src/lite.ts
+++ b/packages/console/core/src/lite.ts
@@ -10,6 +10,7 @@ export namespace LiteData {
export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)
export const priceID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.price)
+ export const priceInr = fn(z.void(), () => Resource.ZEN_LITE_PRICE.priceInr)
export const firstMonth50Coupon = fn(z.void(), () => Resource.ZEN_LITE_PRICE.firstMonth50Coupon)
export const planName = fn(z.void(), () => "lite")
}
diff --git a/packages/console/core/src/schema/billing.sql.ts b/packages/console/core/src/schema/billing.sql.ts
index a5c70c2115..b06ca8966d 100644
--- a/packages/console/core/src/schema/billing.sql.ts
+++ b/packages/console/core/src/schema/billing.sql.ts
@@ -88,6 +88,7 @@ export const PaymentTable = mysqlTable(
enrichment: json("enrichment").$type<
| {
type: "subscription" | "lite"
+ currency?: "inr"
couponID?: string
}
| {
diff --git a/packages/console/core/sst-env.d.ts b/packages/console/core/sst-env.d.ts
index 5e2693ad86..6b842639ad 100644
--- a/packages/console/core/sst-env.d.ts
+++ b/packages/console/core/sst-env.d.ts
@@ -145,6 +145,7 @@ declare module "sst" {
"ZEN_LITE_PRICE": {
"firstMonth50Coupon": string
"price": string
+ "priceInr": number
"product": string
"type": "sst.sst.Linkable"
}
diff --git a/packages/console/function/sst-env.d.ts b/packages/console/function/sst-env.d.ts
index 5e2693ad86..6b842639ad 100644
--- a/packages/console/function/sst-env.d.ts
+++ b/packages/console/function/sst-env.d.ts
@@ -145,6 +145,7 @@ declare module "sst" {
"ZEN_LITE_PRICE": {
"firstMonth50Coupon": string
"price": string
+ "priceInr": number
"product": string
"type": "sst.sst.Linkable"
}
diff --git a/packages/console/resource/sst-env.d.ts b/packages/console/resource/sst-env.d.ts
index 5e2693ad86..6b842639ad 100644
--- a/packages/console/resource/sst-env.d.ts
+++ b/packages/console/resource/sst-env.d.ts
@@ -145,6 +145,7 @@ declare module "sst" {
"ZEN_LITE_PRICE": {
"firstMonth50Coupon": string
"price": string
+ "priceInr": number
"product": string
"type": "sst.sst.Linkable"
}
diff --git a/packages/enterprise/sst-env.d.ts b/packages/enterprise/sst-env.d.ts
index 5e2693ad86..6b842639ad 100644
--- a/packages/enterprise/sst-env.d.ts
+++ b/packages/enterprise/sst-env.d.ts
@@ -145,6 +145,7 @@ declare module "sst" {
"ZEN_LITE_PRICE": {
"firstMonth50Coupon": string
"price": string
+ "priceInr": number
"product": string
"type": "sst.sst.Linkable"
}
diff --git a/packages/function/sst-env.d.ts b/packages/function/sst-env.d.ts
index 5e2693ad86..6b842639ad 100644
--- a/packages/function/sst-env.d.ts
+++ b/packages/function/sst-env.d.ts
@@ -145,6 +145,7 @@ declare module "sst" {
"ZEN_LITE_PRICE": {
"firstMonth50Coupon": string
"price": string
+ "priceInr": number
"product": string
"type": "sst.sst.Linkable"
}
diff --git a/packages/opencode/src/account/effect.ts b/packages/opencode/src/account/effect.ts
index 60d2069527..f46257b783 100644
--- a/packages/opencode/src/account/effect.ts
+++ b/packages/opencode/src/account/effect.ts
@@ -168,6 +168,12 @@ export namespace Account {
mapAccountServiceError("HTTP request failed"),
)
+ const executeEffect =
(request: Effect.Effect) =>
+ request.pipe(
+ Effect.flatMap((req) => http.execute(req)),
+ mapAccountServiceError("HTTP request failed"),
+ )
+
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (row.token_expiry && row.token_expiry > now) return row.access_token
@@ -310,7 +316,7 @@ export namespace Account {
})
const poll = Effect.fn("Account.poll")(function* (input: Login) {
- const response = yield* executeEffectOk(
+ const response = yield* executeEffect(
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts
index b2dae0402c..e30d05e935 100644
--- a/packages/opencode/src/agent/agent.ts
+++ b/packages/opencode/src/agent/agent.ts
@@ -260,7 +260,10 @@ export namespace Agent {
return pipe(
await state(),
values(),
- sortBy([(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"]),
+ sortBy(
+ [(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"],
+ [(x) => x.name, "asc"],
+ ),
)
}
diff --git a/packages/opencode/src/bus/index.ts b/packages/opencode/src/bus/index.ts
index edb093f197..625f296622 100644
--- a/packages/opencode/src/bus/index.ts
+++ b/packages/opencode/src/bus/index.ts
@@ -51,8 +51,8 @@ export namespace Bus {
})
const pending = []
for (const key of [def.type, "*"]) {
- const match = state().subscriptions.get(key)
- for (const sub of match ?? []) {
+ const match = [...(state().subscriptions.get(key) ?? [])]
+ for (const sub of match) {
pending.push(sub(payload))
}
}
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx
index b11ad6a734..09bb492f63 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx
@@ -9,6 +9,7 @@ import { useToast } from "../ui/toast"
import { useKeybind } from "../context/keybind"
import { DialogSessionList } from "./workspace/dialog-session-list"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
+import { setTimeout as sleep } from "node:timers/promises"
async function openWorkspace(input: {
dialog: ReturnType
@@ -56,7 +57,7 @@ async function openWorkspace(input: {
return
}
if (result.response.status >= 500 && result.response.status < 600) {
- await Bun.sleep(1000)
+ await sleep(1000)
continue
}
if (!result.data) {
diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts
index c3c28ed605..e5294844b1 100644
--- a/packages/opencode/src/control-plane/workspace.ts
+++ b/packages/opencode/src/control-plane/workspace.ts
@@ -1,4 +1,5 @@
import z from "zod"
+import { setTimeout as sleep } from "node:timers/promises"
import { fn } from "@/util/fn"
import { Database, eq } from "@/storage/db"
import { Project } from "@/project/project"
@@ -117,7 +118,7 @@ export namespace Workspace {
const adaptor = await getAdaptor(space.type)
const res = await adaptor.fetch(space, "/event", { method: "GET", signal: stop }).catch(() => undefined)
if (!res || !res.ok || !res.body) {
- await Bun.sleep(1000)
+ await sleep(1000)
continue
}
await parseSSE(res.body, stop, (event) => {
@@ -127,7 +128,7 @@ export namespace Workspace {
})
})
// Wait 250ms and retry if SSE connection fails
- await Bun.sleep(250)
+ await sleep(250)
}
}
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
index 0255cf9bf1..f7667fc2cb 100644
--- a/packages/opencode/src/provider/provider.ts
+++ b/packages/opencode/src/provider/provider.ts
@@ -47,8 +47,6 @@ import { ProviderTransform } from "./transform"
import { Installation } from "../installation"
import { ModelID, ProviderID } from "./schema"
-const DEFAULT_CHUNK_TIMEOUT = 300_000
-
export namespace Provider {
const log = Log.create({ service: "provider" })
@@ -1130,7 +1128,7 @@ export namespace Provider {
if (existing) return existing
const customFetch = options["fetch"]
- const chunkTimeout = options["chunkTimeout"] || DEFAULT_CHUNK_TIMEOUT
+ const chunkTimeout = options["chunkTimeout"]
delete options["chunkTimeout"]
options["fetch"] = async (input: any, init?: BunFetchRequestInit) => {
diff --git a/packages/opencode/src/server/routes/event.ts b/packages/opencode/src/server/routes/event.ts
new file mode 100644
index 0000000000..f34ff05667
--- /dev/null
+++ b/packages/opencode/src/server/routes/event.ts
@@ -0,0 +1,85 @@
+import { Hono } from "hono"
+import { describeRoute, resolver } from "hono-openapi"
+import { streamSSE } from "hono/streaming"
+import { Log } from "@/util/log"
+import { BusEvent } from "@/bus/bus-event"
+import { Bus } from "@/bus"
+import { lazy } from "../../util/lazy"
+import { AsyncQueue } from "../../util/queue"
+import { Instance } from "@/project/instance"
+
+const log = Log.create({ service: "server" })
+
+export const EventRoutes = lazy(() =>
+ new Hono().get(
+ "/event",
+ describeRoute({
+ summary: "Subscribe to events",
+ description: "Get events",
+ operationId: "event.subscribe",
+ responses: {
+ 200: {
+ description: "Event stream",
+ content: {
+ "text/event-stream": {
+ schema: resolver(BusEvent.payloads()),
+ },
+ },
+ },
+ },
+ }),
+ async (c) => {
+ log.info("event connected")
+ c.header("X-Accel-Buffering", "no")
+ c.header("X-Content-Type-Options", "nosniff")
+ return streamSSE(c, async (stream) => {
+ const q = new AsyncQueue()
+ let done = false
+
+ q.push(
+ JSON.stringify({
+ type: "server.connected",
+ properties: {},
+ }),
+ )
+
+ // Send heartbeat every 10s to prevent stalled proxy streams.
+ const heartbeat = setInterval(() => {
+ q.push(
+ JSON.stringify({
+ type: "server.heartbeat",
+ properties: {},
+ }),
+ )
+ }, 10_000)
+
+ const unsub = Bus.subscribeAll((event) => {
+ q.push(JSON.stringify(event))
+ if (event.type === Bus.InstanceDisposed.type) {
+ stop()
+ }
+ })
+
+ const stop = () => {
+ if (done) return
+ done = true
+ clearInterval(heartbeat)
+ unsub()
+ q.push(null)
+ log.info("event disconnected")
+ }
+
+ stream.onAbort(stop)
+
+ try {
+ for await (const data of q) {
+ if (data === null) return
+ await stream.writeSSE({ data })
+ }
+ } finally {
+ stop()
+ }
+ })
+ },
+ ),
+)
diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts
index 4d019f6a7e..4a6a3ebc7e 100644
--- a/packages/opencode/src/server/routes/global.ts
+++ b/packages/opencode/src/server/routes/global.ts
@@ -4,6 +4,7 @@ import { streamSSE } from "hono/streaming"
import z from "zod"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
+import { AsyncQueue } from "@/util/queue"
import { Instance } from "../../project/instance"
import { Installation } from "@/installation"
import { Log } from "../../util/log"
@@ -69,41 +70,54 @@ export const GlobalRoutes = lazy(() =>
c.header("X-Accel-Buffering", "no")
c.header("X-Content-Type-Options", "nosniff")
return streamSSE(c, async (stream) => {
- stream.writeSSE({
- data: JSON.stringify({
+ const q = new AsyncQueue()
+ let done = false
+
+ q.push(
+ JSON.stringify({
payload: {
type: "server.connected",
properties: {},
},
}),
- })
- async function handler(event: any) {
- await stream.writeSSE({
- data: JSON.stringify(event),
- })
- }
- GlobalBus.on("event", handler)
+ )
// Send heartbeat every 10s to prevent stalled proxy streams.
const heartbeat = setInterval(() => {
- stream.writeSSE({
- data: JSON.stringify({
+ q.push(
+ JSON.stringify({
payload: {
type: "server.heartbeat",
properties: {},
},
}),
- })
+ )
}, 10_000)
- await new Promise((resolve) => {
- stream.onAbort(() => {
- clearInterval(heartbeat)
- GlobalBus.off("event", handler)
- resolve()
- log.info("global event disconnected")
- })
- })
+ async function handler(event: any) {
+ q.push(JSON.stringify(event))
+ }
+ GlobalBus.on("event", handler)
+
+ const stop = () => {
+ if (done) return
+ done = true
+ clearInterval(heartbeat)
+ GlobalBus.off("event", handler)
+ q.push(null)
+ log.info("event disconnected")
+ }
+
+ stream.onAbort(stop)
+
+ try {
+ for await (const data of q) {
+ if (data === null) return
+ await stream.writeSSE({ data })
+ }
+ } finally {
+ stop()
+ }
})
},
)
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index c485654fdf..a68becb1fb 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -1,10 +1,7 @@
-import { BusEvent } from "@/bus/bus-event"
-import { Bus } from "@/bus"
import { Log } from "../util/log"
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
import { Hono } from "hono"
import { cors } from "hono/cors"
-import { streamSSE } from "hono/streaming"
import { proxy } from "hono/proxy"
import { basicAuth } from "hono/basic-auth"
import z from "zod"
@@ -34,6 +31,7 @@ import { FileRoutes } from "./routes/file"
import { ConfigRoutes } from "./routes/config"
import { ExperimentalRoutes } from "./routes/experimental"
import { ProviderRoutes } from "./routes/provider"
+import { EventRoutes } from "./routes/event"
import { InstanceBootstrap } from "../project/bootstrap"
import { NotFoundError } from "../storage/db"
import type { ContentfulStatusCode } from "hono/utils/http-status"
@@ -251,6 +249,7 @@ export namespace Server {
.route("/question", QuestionRoutes())
.route("/provider", ProviderRoutes())
.route("/", FileRoutes())
+ .route("/", EventRoutes())
.route("/mcp", McpRoutes())
.route("/tui", TuiRoutes())
.post(
@@ -498,64 +497,6 @@ export namespace Server {
return c.json(await Format.status())
},
)
- .get(
- "/event",
- describeRoute({
- summary: "Subscribe to events",
- description: "Get events",
- operationId: "event.subscribe",
- responses: {
- 200: {
- description: "Event stream",
- content: {
- "text/event-stream": {
- schema: resolver(BusEvent.payloads()),
- },
- },
- },
- },
- }),
- async (c) => {
- log.info("event connected")
- c.header("X-Accel-Buffering", "no")
- c.header("X-Content-Type-Options", "nosniff")
- return streamSSE(c, async (stream) => {
- stream.writeSSE({
- data: JSON.stringify({
- type: "server.connected",
- properties: {},
- }),
- })
- const unsub = Bus.subscribeAll(async (event) => {
- await stream.writeSSE({
- data: JSON.stringify(event),
- })
- if (event.type === Bus.InstanceDisposed.type) {
- stream.close()
- }
- })
-
- // Send heartbeat every 10s to prevent stalled proxy streams.
- const heartbeat = setInterval(() => {
- stream.writeSSE({
- data: JSON.stringify({
- type: "server.heartbeat",
- properties: {},
- }),
- })
- }, 10_000)
-
- await new Promise((resolve) => {
- stream.onAbort(() => {
- clearInterval(heartbeat)
- unsub()
- resolve()
- log.info("event disconnected")
- })
- })
- })
- },
- )
.all("/*", async (c) => {
const path = c.req.path
diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts
index d7aeb911f3..5339691a01 100644
--- a/packages/opencode/src/skill/skill.ts
+++ b/packages/opencode/src/skill/skill.ts
@@ -204,7 +204,7 @@ export namespace Skill {
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
yield* Effect.promise(() => state.ensure())
- const list = Object.values(state.skills)
+ const list = Object.values(state.skills).toSorted((a, b) => a.name.localeCompare(b.name))
if (!agent) return list
return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny")
})
diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts
index 14ecea1075..9cabf47eb1 100644
--- a/packages/opencode/src/tool/task.ts
+++ b/packages/opencode/src/tool/task.ts
@@ -33,10 +33,11 @@ export const TaskTool = Tool.define("task", async (ctx) => {
const accessibleAgents = caller
? agents.filter((a) => PermissionNext.evaluate("task", a.name, caller.permission).action !== "deny")
: agents
+ const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
const description = DESCRIPTION.replace(
"{agents}",
- accessibleAgents
+ list
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
.join("\n"),
)
diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts
index 7cb3005b4e..8060228250 100644
--- a/packages/opencode/test/account/service.test.ts
+++ b/packages/opencode/test/account/service.test.ts
@@ -34,6 +34,26 @@ const encodeOrg = Schema.encodeSync(Org)
const org = (id: string, name: string) => encodeOrg(new Org({ id: OrgID.make(id), name }))
+const login = () =>
+ new Login({
+ code: DeviceCode.make("device-code"),
+ user: UserCode.make("user-code"),
+ url: "https://one.example.com/verify",
+ server: "https://one.example.com",
+ expiry: Duration.seconds(600),
+ interval: Duration.seconds(5),
+ })
+
+const deviceTokenClient = (body: unknown, status = 400) =>
+ HttpClient.make((req) =>
+ Effect.succeed(
+ req.url === "https://one.example.com/auth/device/token" ? json(req, body, status) : json(req, {}, 404),
+ ),
+ )
+
+const poll = (body: unknown, status = 400) =>
+ AccountEffect.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status))))
+
it.effect("orgsByAccount groups orgs per account", () =>
Effect.gen(function* () {
yield* AccountRepo.use((r) =>
@@ -172,15 +192,6 @@ it.effect("config sends the selected org header", () =>
it.effect("poll stores the account and first org on success", () =>
Effect.gen(function* () {
- const login = new Login({
- code: DeviceCode.make("device-code"),
- user: UserCode.make("user-code"),
- url: "https://one.example.com/verify",
- server: "https://one.example.com",
- expiry: Duration.seconds(600),
- interval: Duration.seconds(5),
- })
-
const client = HttpClient.make((req) =>
Effect.succeed(
req.url === "https://one.example.com/auth/device/token"
@@ -198,7 +209,7 @@ it.effect("poll stores the account and first org on success", () =>
),
)
- const res = yield* Account.Service.use((s) => s.poll(login)).pipe(Effect.provide(live(client)))
+ const res = yield* Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(client)))
expect(res._tag).toBe("PollSuccess")
if (res._tag === "PollSuccess") {
@@ -215,3 +226,59 @@ it.effect("poll stores the account and first org on success", () =>
)
}),
)
+
+for (const [name, body, expectedTag] of [
+ [
+ "pending",
+ {
+ error: "authorization_pending",
+ error_description: "The authorization request is still pending",
+ },
+ "PollPending",
+ ],
+ [
+ "slow",
+ {
+ error: "slow_down",
+ error_description: "Polling too frequently, please slow down",
+ },
+ "PollSlow",
+ ],
+ [
+ "denied",
+ {
+ error: "access_denied",
+ error_description: "The authorization request was denied",
+ },
+ "PollDenied",
+ ],
+ [
+ "expired",
+ {
+ error: "expired_token",
+ error_description: "The device code has expired",
+ },
+ "PollExpired",
+ ],
+] as const) {
+ it.effect(`poll returns ${name} for ${body.error}`, () =>
+ Effect.gen(function* () {
+ const result = yield* poll(body)
+ expect(result._tag).toBe(expectedTag)
+ }),
+ )
+}
+
+it.effect("poll returns poll error for other OAuth errors", () =>
+ Effect.gen(function* () {
+ const result = yield* poll({
+ error: "server_error",
+ error_description: "An unexpected error occurred",
+ })
+
+ expect(result._tag).toBe("PollError")
+ if (result._tag === "PollError") {
+ expect(String(result.cause)).toContain("server_error")
+ }
+ }),
+)
diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts
index d6b6ebb33b..60c8e57c92 100644
--- a/packages/opencode/test/agent/agent.test.ts
+++ b/packages/opencode/test/agent/agent.test.ts
@@ -384,6 +384,32 @@ test("multiple custom agents can be defined", async () => {
})
})
+test("Agent.list keeps the default agent first and sorts the rest by name", async () => {
+ await using tmp = await tmpdir({
+ config: {
+ default_agent: "plan",
+ agent: {
+ zebra: {
+ description: "Zebra",
+ mode: "subagent",
+ },
+ alpha: {
+ description: "Alpha",
+ mode: "subagent",
+ },
+ },
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const names = (await Agent.list()).map((a) => a.name)
+ expect(names[0]).toBe("plan")
+ expect(names.slice(1)).toEqual(names.slice(1).toSorted((a, b) => a.localeCompare(b)))
+ },
+ })
+})
+
test("Agent.get returns undefined for non-existent agent", async () => {
await using tmp = await tmpdir()
await Instance.provide({
diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts
new file mode 100644
index 0000000000..47f5f6fc25
--- /dev/null
+++ b/packages/opencode/test/session/system.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, test } from "bun:test"
+import path from "path"
+import { Agent } from "../../src/agent/agent"
+import { Instance } from "../../src/project/instance"
+import { SystemPrompt } from "../../src/session/system"
+import { tmpdir } from "../fixture/fixture"
+
+describe("session.system", () => {
+ test("skills output is sorted by name and stable across calls", async () => {
+ await using tmp = await tmpdir({
+ git: true,
+ init: async (dir) => {
+ for (const [name, description] of [
+ ["zeta-skill", "Zeta skill."],
+ ["alpha-skill", "Alpha skill."],
+ ["middle-skill", "Middle skill."],
+ ]) {
+ const skillDir = path.join(dir, ".opencode", "skill", name)
+ await Bun.write(
+ path.join(skillDir, "SKILL.md"),
+ `---
+name: ${name}
+description: ${description}
+---
+
+# ${name}
+`,
+ )
+ }
+ },
+ })
+
+ const home = process.env.OPENCODE_TEST_HOME
+ process.env.OPENCODE_TEST_HOME = tmp.path
+
+ try {
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const build = await Agent.get("build")
+ const first = await SystemPrompt.skills(build!)
+ const second = await SystemPrompt.skills(build!)
+
+ expect(first).toBe(second)
+
+ const alpha = first!.indexOf("alpha-skill ")
+ const middle = first!.indexOf("middle-skill ")
+ const zeta = first!.indexOf("zeta-skill ")
+
+ expect(alpha).toBeGreaterThan(-1)
+ expect(middle).toBeGreaterThan(alpha)
+ expect(zeta).toBeGreaterThan(middle)
+ },
+ })
+ } finally {
+ process.env.OPENCODE_TEST_HOME = home
+ }
+ })
+})
diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts
index 7cfaee1353..f622341d33 100644
--- a/packages/opencode/test/tool/skill.test.ts
+++ b/packages/opencode/test/tool/skill.test.ts
@@ -54,6 +54,56 @@ description: Skill for tool tests.
}
})
+ test("description sorts skills by name and is stable across calls", async () => {
+ await using tmp = await tmpdir({
+ git: true,
+ init: async (dir) => {
+ for (const [name, description] of [
+ ["zeta-skill", "Zeta skill."],
+ ["alpha-skill", "Alpha skill."],
+ ["middle-skill", "Middle skill."],
+ ]) {
+ const skillDir = path.join(dir, ".opencode", "skill", name)
+ await Bun.write(
+ path.join(skillDir, "SKILL.md"),
+ `---
+name: ${name}
+description: ${description}
+---
+
+# ${name}
+`,
+ )
+ }
+ },
+ })
+
+ const home = process.env.OPENCODE_TEST_HOME
+ process.env.OPENCODE_TEST_HOME = tmp.path
+
+ try {
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const first = await SkillTool.init()
+ const second = await SkillTool.init()
+
+ expect(first.description).toBe(second.description)
+
+ const alpha = first.description.indexOf("**alpha-skill**: Alpha skill.")
+ const middle = first.description.indexOf("**middle-skill**: Middle skill.")
+ const zeta = first.description.indexOf("**zeta-skill**: Zeta skill.")
+
+ expect(alpha).toBeGreaterThan(-1)
+ expect(middle).toBeGreaterThan(alpha)
+ expect(zeta).toBeGreaterThan(middle)
+ },
+ })
+ } finally {
+ process.env.OPENCODE_TEST_HOME = home
+ }
+ })
+
test("execute returns skill content block with files", async () => {
await using tmp = await tmpdir({
git: true,
diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts
new file mode 100644
index 0000000000..df319d8de1
--- /dev/null
+++ b/packages/opencode/test/tool/task.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, test } from "bun:test"
+import { Agent } from "../../src/agent/agent"
+import { Instance } from "../../src/project/instance"
+import { TaskTool } from "../../src/tool/task"
+import { tmpdir } from "../fixture/fixture"
+
+describe("tool.task", () => {
+ test("description sorts subagents by name and is stable across calls", async () => {
+ await using tmp = await tmpdir({
+ config: {
+ agent: {
+ zebra: {
+ description: "Zebra agent",
+ mode: "subagent",
+ },
+ alpha: {
+ description: "Alpha agent",
+ mode: "subagent",
+ },
+ },
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const build = await Agent.get("build")
+ const first = await TaskTool.init({ agent: build })
+ const second = await TaskTool.init({ agent: build })
+
+ expect(first.description).toBe(second.description)
+
+ const alpha = first.description.indexOf("- alpha: Alpha agent")
+ const explore = first.description.indexOf("- explore:")
+ const general = first.description.indexOf("- general:")
+ const zebra = first.description.indexOf("- zebra: Zebra agent")
+
+ expect(alpha).toBeGreaterThan(-1)
+ expect(explore).toBeGreaterThan(alpha)
+ expect(general).toBeGreaterThan(explore)
+ expect(zebra).toBeGreaterThan(general)
+ },
+ })
+ })
+})
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index aa759bb1e0..b6821322e2 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -2845,6 +2845,38 @@ export class File extends HeyApiClient {
}
}
+export class Event extends HeyApiClient {
+ /**
+ * Subscribe to events
+ *
+ * Get events
+ */
+ public subscribe(
+ 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).sse.get({
+ url: "/event",
+ ...options,
+ ...params,
+ })
+ }
+}
+
export class Auth2 extends HeyApiClient {
/**
* Remove MCP OAuth
@@ -3866,38 +3898,6 @@ export class Formatter extends HeyApiClient {
}
}
-export class Event extends HeyApiClient {
- /**
- * Subscribe to events
- *
- * Get events
- */
- public subscribe(
- 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).sse.get({
- url: "/event",
- ...options,
- ...params,
- })
- }
-}
-
export class OpencodeClient extends HeyApiClient {
public static readonly __registry = new HeyApiRegistry()
@@ -3981,6 +3981,11 @@ export class OpencodeClient extends HeyApiClient {
return (this._file ??= new File({ client: this.client }))
}
+ private _event?: Event
+ get event(): Event {
+ return (this._event ??= new Event({ client: this.client }))
+ }
+
private _mcp?: Mcp
get mcp(): Mcp {
return (this._mcp ??= new Mcp({ client: this.client }))
@@ -4025,9 +4030,4 @@ export class OpencodeClient extends HeyApiClient {
get formatter(): Formatter {
return (this._formatter ??= new Formatter({ client: this.client }))
}
-
- private _event?: Event
- get event(): Event {
- return (this._event ??= new Event({ 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 41aa248171..ec797f2ba8 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -4229,6 +4229,25 @@ export type FileStatusResponses = {
export type FileStatusResponse = FileStatusResponses[keyof FileStatusResponses]
+export type EventSubscribeData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/event"
+}
+
+export type EventSubscribeResponses = {
+ /**
+ * Event stream
+ */
+ 200: Event
+}
+
+export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses]
+
export type McpStatusData = {
body?: never
path?: never
@@ -4979,22 +4998,3 @@ export type FormatterStatusResponses = {
}
export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses]
-
-export type EventSubscribeData = {
- body?: never
- path?: never
- query?: {
- directory?: string
- workspace?: string
- }
- url: "/event"
-}
-
-export type EventSubscribeResponses = {
- /**
- * Event stream
- */
- 200: Event
-}
-
-export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses]
diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json
index 350395423f..fa894045e1 100644
--- a/packages/sdk/openapi.json
+++ b/packages/sdk/openapi.json
@@ -5243,6 +5243,47 @@
]
}
},
+ "/event": {
+ "get": {
+ "operationId": "event.subscribe",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "directory",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "workspace",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "summary": "Subscribe to events",
+ "description": "Get events",
+ "responses": {
+ "200": {
+ "description": "Event stream",
+ "content": {
+ "text/event-stream": {
+ "schema": {
+ "$ref": "#/components/schemas/Event"
+ }
+ }
+ }
+ }
+ },
+ "x-codeSamples": [
+ {
+ "lang": "js",
+ "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.event.subscribe({\n ...\n})"
+ }
+ ]
+ }
+ },
"/mcp": {
"get": {
"operationId": "mcp.status",
@@ -6894,47 +6935,6 @@
}
]
}
- },
- "/event": {
- "get": {
- "operationId": "event.subscribe",
- "parameters": [
- {
- "in": "query",
- "name": "directory",
- "schema": {
- "type": "string"
- }
- },
- {
- "in": "query",
- "name": "workspace",
- "schema": {
- "type": "string"
- }
- }
- ],
- "summary": "Subscribe to events",
- "description": "Get events",
- "responses": {
- "200": {
- "description": "Event stream",
- "content": {
- "text/event-stream": {
- "schema": {
- "$ref": "#/components/schemas/Event"
- }
- }
- }
- }
- },
- "x-codeSamples": [
- {
- "lang": "js",
- "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.event.subscribe({\n ...\n})"
- }
- ]
- }
}
},
"components": {
diff --git a/sst-env.d.ts b/sst-env.d.ts
index e6bcc7ab11..c9e567997b 100644
--- a/sst-env.d.ts
+++ b/sst-env.d.ts
@@ -171,6 +171,7 @@ declare module "sst" {
"ZEN_LITE_PRICE": {
"firstMonth50Coupon": string
"price": string
+ "priceInr": number
"product": string
"type": "sst.sst.Linkable"
}