From f24251f89e277cab2669730dc1e028573e0fe082 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 13 Jan 2026 13:36:37 -0500 Subject: [PATCH 01/87] sync --- bun.lock | 6 + packages/console/app/package.json | 2 + packages/console/app/src/config.ts | 6 + .../app/src/routes/api/black/setup-intent.ts | 30 ++ .../console/app/src/routes/auth/authorize.ts | 11 +- .../console/app/src/routes/auth/callback.ts | 2 + .../src/routes/{black/index.css => black.css} | 264 ++++++++++++++- packages/console/app/src/routes/black.tsx | 166 +++++++++ .../console/app/src/routes/black/common.tsx | 42 +++ .../console/app/src/routes/black/index.tsx | 318 ++++-------------- .../app/src/routes/black/subscribe.tsx | 244 ++++++++++++++ 11 files changed, 827 insertions(+), 264 deletions(-) create mode 100644 packages/console/app/src/routes/api/black/setup-intent.ts rename packages/console/app/src/routes/{black/index.css => black.css} (62%) create mode 100644 packages/console/app/src/routes/black.tsx create mode 100644 packages/console/app/src/routes/black/common.tsx create mode 100644 packages/console/app/src/routes/black/subscribe.tsx diff --git a/bun.lock b/bun.lock index 10001bb61958..563c13a33a97 100644 --- a/bun.lock +++ b/bun.lock @@ -84,10 +84,12 @@ "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "@solidjs/start": "catalog:", + "@stripe/stripe-js": "8.6.1", "chart.js": "4.5.1", "nitro": "3.0.1-alpha.1", "solid-js": "catalog:", "solid-list": "0.3.0", + "solid-stripe": "0.8.1", "vite": "catalog:", "zod": "catalog:", }, @@ -1652,6 +1654,8 @@ "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], + "@stripe/stripe-js": ["@stripe/stripe-js@8.6.1", "", {}, "sha512-UJ05U2062XDgydbUcETH1AoRQLNhigQ2KmDn1BG8sC3xfzu6JKg95Qt6YozdzFpxl1Npii/02m2LEWFt1RYjVA=="], + "@swc/helpers": ["@swc/helpers@0.5.17", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="], "@tailwindcss/node": ["@tailwindcss/node@4.1.11", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.11" } }, "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q=="], @@ -3528,6 +3532,8 @@ "solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="], + "solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="], + "solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="], "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 9557f831040e..23171daac887 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -23,10 +23,12 @@ "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "@solidjs/start": "catalog:", + "@stripe/stripe-js": "8.6.1", "chart.js": "4.5.1", "nitro": "3.0.1-alpha.1", "solid-js": "catalog:", "solid-list": "0.3.0", + "solid-stripe": "0.8.1", "vite": "catalog:", "zod": "catalog:" }, diff --git a/packages/console/app/src/config.ts b/packages/console/app/src/config.ts index 4ebb2c71abe1..4396e5117198 100644 --- a/packages/console/app/src/config.ts +++ b/packages/console/app/src/config.ts @@ -26,4 +26,10 @@ export const config = { commits: "6,500", monthlyUsers: "650,000", }, + + // Stripe + stripe: { + publishableKey: + "pk_live_51OhXSKEclFNgdHcR9dDfYGwQeKuPfKo0IjA5kWBQIXKMFhE8QFd9bYLdPZC6klRKEgEkxJYSKuZg9U3FKHdLnF4300F9qLqMgP", + }, } as const diff --git a/packages/console/app/src/routes/api/black/setup-intent.ts b/packages/console/app/src/routes/api/black/setup-intent.ts new file mode 100644 index 000000000000..eb5571616009 --- /dev/null +++ b/packages/console/app/src/routes/api/black/setup-intent.ts @@ -0,0 +1,30 @@ +import type { APIEvent } from "@solidjs/start/server" +import { Billing } from "@opencode-ai/console-core/billing.js" + +export async function POST(event: APIEvent) { + try { + const body = (await event.request.json()) as { plan: string } + const plan = body.plan + + if (!plan || !["20", "100", "200"].includes(plan)) { + return Response.json({ error: "Invalid plan" }, { status: 400 }) + } + + const amount = parseInt(plan) * 100 + + const intent = await Billing.stripe().setupIntents.create({ + payment_method_types: ["card"], + metadata: { + plan, + amount: amount.toString(), + }, + }) + + return Response.json({ + clientSecret: intent.client_secret, + }) + } catch (error) { + console.error("Error creating setup intent:", error) + return Response.json({ error: "Internal server error" }, { status: 500 }) + } +} diff --git a/packages/console/app/src/routes/auth/authorize.ts b/packages/console/app/src/routes/auth/authorize.ts index 166466ef8596..6be94b14695e 100644 --- a/packages/console/app/src/routes/auth/authorize.ts +++ b/packages/console/app/src/routes/auth/authorize.ts @@ -2,6 +2,13 @@ import type { APIEvent } from "@solidjs/start/server" import { AuthClient } from "~/context/auth" export async function GET(input: APIEvent) { - const result = await AuthClient.authorize(new URL("./callback", input.request.url).toString(), "code") - return Response.redirect(result.url, 302) + const url = new URL(input.request.url) + // TODO + // input.request.url http://localhost:3001/auth/authorize?continue=/black/subscribe + const result = await AuthClient.authorize( + new URL("/callback/subscribe?foo=bar", input.request.url).toString(), + "code", + ) + // result.url https://auth.frank.dev.opencode.ai/authorize?client_id=app&redirect_uri=http%3A%2F%2Flocalhost%3A3001%2Fauth%2Fcallback&response_type=code&state=0d3fc834-bcbc-42dc-83ab-c25c2c43c7e3 + return Response.redirect(result.url + "&continue=" + url.searchParams.get("continue"), 302) } diff --git a/packages/console/app/src/routes/auth/callback.ts b/packages/console/app/src/routes/auth/callback.ts index 9b7296791d43..b03bbdbe55b3 100644 --- a/packages/console/app/src/routes/auth/callback.ts +++ b/packages/console/app/src/routes/auth/callback.ts @@ -5,6 +5,8 @@ import { useAuthSession } from "~/context/auth" export async function GET(input: APIEvent) { const url = new URL(input.request.url) + console.log("=C=", input.request.url) + throw new Error("Not implemented") try { const code = url.searchParams.get("code") if (!code) throw new Error("No code found") diff --git a/packages/console/app/src/routes/black/index.css b/packages/console/app/src/routes/black.css similarity index 62% rename from packages/console/app/src/routes/black/index.css rename to packages/console/app/src/routes/black.css index 418598792fbc..dfb188ed02af 100644 --- a/packages/console/app/src/routes/black/index.css +++ b/packages/console/app/src/routes/black.css @@ -36,24 +36,73 @@ width: 100%; flex-grow: 1; + [data-slot="hero"] { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 8px; + margin-top: 40px; + padding: 0 20px; + + @media (min-width: 768px) { + margin-top: 60px; + } + + h1 { + color: rgba(255, 255, 255, 0.92); + font-size: 18px; + font-style: normal; + font-weight: 400; + line-height: 160%; + margin: 0; + + @media (min-width: 768px) { + font-size: 24px; + } + } + + p { + color: rgba(255, 255, 255, 0.59); + font-size: 15px; + font-style: normal; + font-weight: 400; + line-height: 160%; + margin: 0; + + @media (min-width: 768px) { + font-size: 18px; + } + } + } + [data-slot="hero-black"] { - margin-top: 110px; + margin-top: 40px; + padding: 0 20px; @media (min-width: 768px) { - margin-top: 150px; + margin-top: 60px; + } + + svg { + width: 100%; + max-width: 540px; + height: auto; + filter: drop-shadow(0 0 20px rgba(255, 255, 255, 0.1)); } } [data-slot="cta"] { display: flex; flex-direction: column; - gap: 32px; + gap: 16px; align-items: center; text-align: center; - margin-top: -18px; + margin-top: -40px; + width: 100%; @media (min-width: 768px) { - margin-top: 40px; + margin-top: -20px; } [data-slot="heading"] { @@ -328,6 +377,211 @@ } } } + + /* Subscribe page styles */ + [data-slot="subscribe-form"] { + display: flex; + flex-direction: column; + gap: 32px; + align-items: center; + margin-top: -18px; + width: 100%; + max-width: 540px; + padding: 0 20px; + + @media (min-width: 768px) { + margin-top: 40px; + padding: 0; + } + + [data-slot="form-card"] { + width: 100%; + border: 1px solid rgba(255, 255, 255, 0.17); + border-radius: 4px; + padding: 24px; + display: flex; + flex-direction: column; + gap: 20px; + } + + [data-slot="plan-header"] { + display: flex; + flex-direction: column; + gap: 8px; + } + + [data-slot="title"] { + color: rgba(255, 255, 255, 0.92); + font-size: 16px; + font-weight: 400; + margin-bottom: 8px; + } + + [data-slot="icon"] { + color: rgba(255, 255, 255, 0.59); + } + + [data-slot="price"] { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px; + } + + [data-slot="amount"] { + color: rgba(255, 255, 255, 0.92); + font-size: 24px; + font-weight: 500; + } + + [data-slot="period"] { + color: rgba(255, 255, 255, 0.59); + font-size: 14px; + } + + [data-slot="multiplier"] { + color: rgba(255, 255, 255, 0.39); + font-size: 14px; + + &::before { + content: "·"; + margin: 0 8px; + } + } + + [data-slot="divider"] { + height: 1px; + background: rgba(255, 255, 255, 0.17); + } + + [data-slot="section-title"] { + color: rgba(255, 255, 255, 0.92); + font-size: 16px; + font-weight: 400; + } + + [data-slot="checkout-form"] { + display: flex; + flex-direction: column; + gap: 20px; + } + + [data-slot="error"] { + color: #ff6b6b; + font-size: 14px; + } + + [data-slot="submit-button"] { + width: 100%; + height: 48px; + background: rgba(255, 255, 255, 0.92); + border: none; + border-radius: 4px; + color: #000; + font-family: var(--font-mono); + font-size: 16px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s ease; + + &:hover:not(:disabled) { + background: #e0e0e0; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + + [data-slot="charge-notice"] { + color: #d4a500; + font-size: 14px; + text-align: center; + } + + [data-slot="loading"] { + display: flex; + justify-content: center; + padding: 40px 0; + + p { + color: rgba(255, 255, 255, 0.59); + font-size: 14px; + } + } + + [data-slot="fine-print"] { + color: rgba(255, 255, 255, 0.39); + text-align: center; + font-size: 13px; + font-style: italic; + + a { + color: rgba(255, 255, 255, 0.39); + text-decoration: underline; + } + } + + [data-slot="workspace-picker"] { + [data-slot="workspace-list"] { + width: 100%; + padding: 0; + margin: 0; + list-style: none; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + align-self: stretch; + outline: none; + overflow-y: auto; + max-height: 240px; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + + [data-slot="workspace-item"] { + width: 100%; + display: flex; + padding: 8px 12px; + align-items: center; + gap: 8px; + align-self: stretch; + cursor: pointer; + + [data-slot="selected-icon"] { + visibility: hidden; + color: rgba(255, 255, 255, 0.39); + font-family: "IBM Plex Mono", monospace; + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 160%; + } + + span:last-child { + color: rgba(255, 255, 255, 0.92); + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 160%; + } + + &:hover, + &[data-active="true"] { + background: #161616; + + [data-slot="selected-icon"] { + visibility: visible; + } + } + } + } + } + } } [data-component="footer"] { diff --git a/packages/console/app/src/routes/black.tsx b/packages/console/app/src/routes/black.tsx new file mode 100644 index 000000000000..5a5b139dd534 --- /dev/null +++ b/packages/console/app/src/routes/black.tsx @@ -0,0 +1,166 @@ +import { A, createAsync, RouteSectionProps } from "@solidjs/router" +import { createMemo } from "solid-js" +import { github } from "~/lib/github" +import { config } from "~/config" +import "./black.css" + +export default function BlackLayout(props: RouteSectionProps) { + const githubData = createAsync(() => github()) + const starCount = createMemo(() => + githubData()?.stars + ? new Intl.NumberFormat("en-US", { + notation: "compact", + compactDisplay: "short", + }).format(githubData()!.stars!) + : config.github.starsFormatted.compact, + ) + + return ( +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+

Access all the world's best coding models

+

Including Claude, GPT, Gemini and more

+
+
+ + + + + + + + + + + + + + + + + + +
+ {props.children} +
+ +
+ ) +} diff --git a/packages/console/app/src/routes/black/common.tsx b/packages/console/app/src/routes/black/common.tsx new file mode 100644 index 000000000000..c1184bd20c61 --- /dev/null +++ b/packages/console/app/src/routes/black/common.tsx @@ -0,0 +1,42 @@ +import { Match, Switch } from "solid-js" + +export const plans = [ + { id: "20", amount: 20, multiplier: null }, + { id: "100", amount: 100, multiplier: "6x more usage than Black 20" }, + { id: "200", amount: 200, multiplier: "21x more usage than Black 20" }, +] as const + +export type Plan = (typeof plans)[number] + +export function PlanIcon(props: { plan: string }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/packages/console/app/src/routes/black/index.tsx b/packages/console/app/src/routes/black/index.tsx index f5a375adf870..2b452c812932 100644 --- a/packages/console/app/src/routes/black/index.tsx +++ b/packages/console/app/src/routes/black/index.tsx @@ -1,276 +1,80 @@ -import { A, createAsync, useSearchParams } from "@solidjs/router" -import "./index.css" +import { A, useSearchParams } from "@solidjs/router" import { Title } from "@solidjs/meta" -import { github } from "~/lib/github" import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js" -import { config } from "~/config" - -const plans = [ - { id: "20", amount: 20, multiplier: null }, - { id: "100", amount: 100, multiplier: "6x more usage than Black 20" }, - { id: "200", amount: 200, multiplier: "21x more usage than Black 20" }, -] as const - -function PlanIcon(props: { plan: string }) { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) -} +import { PlanIcon, plans } from "./common" export default function Black() { const [params] = useSearchParams() - const [selected, setSelected] = createSignal(params.plan as string | null) + const [selected, setSelected] = createSignal((params.plan as string) || null) const selectedPlan = createMemo(() => plans.find((p) => p.id === selected())) - const githubData = createAsync(() => github()) - const starCount = createMemo(() => - githubData()?.stars - ? new Intl.NumberFormat("en-US", { - notation: "compact", - compactDisplay: "short", - }).format(githubData()!.stars!) - : config.github.starsFormatted.compact, - ) - return ( -
+ <> opencode -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
- - - - - - - - - - - - - - - - - - -
-
-
-

- Access all the world's best coding models -

-

Including Claude, GPT, Gemini, and more

-
- - -
- - {(plan) => ( - - )} - -
-

- Prices shown don't include applicable tax · Terms of Service -

-
- - {(plan) => ( -
-
+
+ + +
+ + {(plan) => ( + - - Continue - -
+ + )} + +
+

+ Prices shown don't include applicable tax · Terms of Service +

+ + + {(plan) => ( +
+
+
+
-

- Prices shown don't include applicable tax · Terms of Service +

+ ${plan().amount}{" "} + per person billed monthly + + {plan().multiplier} +

+
    +
  • Your subscription will not start immediately
  • +
  • You will be added to the waitlist and activated soon
  • +
  • Your card will be only charged when your subscription is activated
  • +
  • Usage limits apply, heavily automated use may reach limits sooner
  • +
  • Subscriptions for individuals, contact Enterprise for teams
  • +
  • Limits may be adjusted and plans may be discontinued in the future
  • +
  • Cancel your subscription at anytime
  • +
+
+ + + Continue + +
- )} - - -
-
- -
+

+ Prices shown don't include applicable tax · Terms of Service +

+
+ )} + + + + ) } diff --git a/packages/console/app/src/routes/black/subscribe.tsx b/packages/console/app/src/routes/black/subscribe.tsx new file mode 100644 index 000000000000..00ce19ef691f --- /dev/null +++ b/packages/console/app/src/routes/black/subscribe.tsx @@ -0,0 +1,244 @@ +import { A, createAsync, query, redirect, useSearchParams } from "@solidjs/router" +import { Title } from "@solidjs/meta" +import { createEffect, createSignal, For, onMount, Show } from "solid-js" +import { loadStripe } from "@stripe/stripe-js" +import { Elements, PaymentElement, useStripe, useElements } from "solid-stripe" +import { config } from "~/config" +import { PlanIcon, plans } from "./common" +import { getActor } from "~/context/auth" +import { withActor } from "~/context/auth.withActor" +import { Actor } from "@opencode-ai/console-core/actor.js" +import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js" +import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js" +import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js" +import { createList } from "solid-list" +import { Modal } from "~/component/modal" + +const plansMap = Object.fromEntries(plans.map((p) => [p.id, p])) as Record + +const getWorkspaces = query(async () => { + "use server" + const actor = await getActor() + if (actor.type === "public") throw redirect("/auth/authorize?continue=/black/subscribe") + return withActor(async () => { + return Database.use((tx) => + tx + .select({ + id: WorkspaceTable.id, + name: WorkspaceTable.name, + slug: WorkspaceTable.slug, + }) + .from(UserTable) + .innerJoin(WorkspaceTable, eq(UserTable.workspaceID, WorkspaceTable.id)) + .where( + and( + eq(UserTable.accountID, Actor.account()), + isNull(WorkspaceTable.timeDeleted), + isNull(UserTable.timeDeleted), + ), + ), + ) + }) +}, "black.subscribe.workspaces") + +function CheckoutForm(props: { plan: string; amount: number }) { + const stripe = useStripe() + const elements = useElements() + const [error, setError] = createSignal(null) + const [loading, setLoading] = createSignal(false) + + const handleSubmit = async (e: Event) => { + e.preventDefault() + if (!stripe() || !elements()) return + + setLoading(true) + setError(null) + + const result = await elements()!.submit() + if (result.error) { + setError(result.error.message ?? "An error occurred") + setLoading(false) + return + } + + const { error: confirmError } = await stripe()!.confirmSetup({ + elements: elements()!, + confirmParams: { + return_url: `${window.location.origin}/black/success?plan=${props.plan}`, + }, + }) + + if (confirmError) { + setError(confirmError.message ?? "An error occurred") + } + setLoading(false) + } + + return ( +
+ + +

{error()}

+
+ +

You will only be charged when your subscription is activated

+ + ) +} + +export default function BlackSubscribe() { + const workspaces = createAsync(() => getWorkspaces()) + const [selectedWorkspace, setSelectedWorkspace] = createSignal(null) + + const [params] = useSearchParams() + const plan = (params.plan as string) || "200" + const planData = plansMap[plan] || plansMap["200"] + + const [clientSecret, setClientSecret] = createSignal(null) + const [stripePromise] = createSignal(loadStripe(config.stripe.publishableKey)) + + // Auto-select if only one workspace + createEffect(() => { + const ws = workspaces() + if (ws?.length === 1 && !selectedWorkspace()) { + setSelectedWorkspace(ws[0].id) + } + }) + + // Keyboard navigation for workspace picker + const { active, setActive, onKeyDown } = createList({ + items: () => workspaces()?.map((w) => w.id) ?? [], + initialActive: null, + }) + + const handleSelectWorkspace = (id: string) => { + setSelectedWorkspace(id) + } + + onMount(async () => { + const response = await fetch("/api/black/setup-intent", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ plan }), + }) + const data = await response.json() + if (data.clientSecret) { + setClientSecret(data.clientSecret) + } + }) + + let listRef: HTMLUListElement | undefined + + // Show workspace picker if multiple workspaces and none selected + const showWorkspacePicker = () => { + const ws = workspaces() + return ws && ws.length > 1 && !selectedWorkspace() + } + + return ( + <> + Subscribe to OpenCode Black +
+
+
+

Subscribe to OpenCode Black

+
+ +
+

+ ${planData.amount} per month + + {planData.multiplier} + +

+
+
+

Add payment method

+ +

Loading payment form...

+
+ } + > + + + + +
+ + {/* Workspace picker modal */} + {}} title="Select a workspace for this plan"> +
+
    { + if (e.key === "Enter" && active()) { + handleSelectWorkspace(active()!) + } else { + onKeyDown(e) + } + }} + > + + {(workspace) => ( +
  • setActive(workspace.id)} + onClick={() => handleSelectWorkspace(workspace.id)} + > + [*] + {workspace.name || workspace.slug} +
  • + )} +
    +
+
+
+

+ Prices shown don't include applicable tax · Terms of Service +

+
+ + ) +} From 8ae10f1c9462a583b4de1d0c6170e260f95d3020 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 13 Jan 2026 13:37:48 -0500 Subject: [PATCH 02/87] sync --- packages/console/app/src/routes/auth/callback.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/console/app/src/routes/auth/callback.ts b/packages/console/app/src/routes/auth/callback.ts index b03bbdbe55b3..9b7296791d43 100644 --- a/packages/console/app/src/routes/auth/callback.ts +++ b/packages/console/app/src/routes/auth/callback.ts @@ -5,8 +5,6 @@ import { useAuthSession } from "~/context/auth" export async function GET(input: APIEvent) { const url = new URL(input.request.url) - console.log("=C=", input.request.url) - throw new Error("Not implemented") try { const code = url.searchParams.get("code") if (!code) throw new Error("No code found") From b7a1d8f2f5540d6451b93ef6c425314408b17645 Mon Sep 17 00:00:00 2001 From: Github Action Date: Tue, 13 Jan 2026 18:39:01 +0000 Subject: [PATCH 03/87] Update Nix flake.lock and x86_64-linux hash --- nix/hashes.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/hashes.json b/nix/hashes.json index 4c953c5a8786..7e3d51ab5799 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,6 +1,6 @@ { "nodeModules": { - "x86_64-linux": "sha256-UCPTTk4b7d2bets7KgCeYBHWAUwUAPUyKm+xDYkSexE=", + "x86_64-linux": "sha256-e3pcCRHba4B5aYIvdteL+PYW2KHO6Ry1qO4DoMn+erE=", "aarch64-darwin": "sha256-Y3o6lovahSWoG9un/l1qxu7hCmIlZXm2LxOLKNiPQfQ=" } } From f3d4dd5099003070800cc9ec161877634fdd7c0a Mon Sep 17 00:00:00 2001 From: Github Action Date: Tue, 13 Jan 2026 18:43:58 +0000 Subject: [PATCH 04/87] Update aarch64-darwin hash --- nix/hashes.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/hashes.json b/nix/hashes.json index 7e3d51ab5799..1dbc18960a13 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,6 +1,6 @@ { "nodeModules": { "x86_64-linux": "sha256-e3pcCRHba4B5aYIvdteL+PYW2KHO6Ry1qO4DoMn+erE=", - "aarch64-darwin": "sha256-Y3o6lovahSWoG9un/l1qxu7hCmIlZXm2LxOLKNiPQfQ=" + "aarch64-darwin": "sha256-xF9TVBw8aYloNbQLLd19ywwdPIHyS12ktMPhzO+cYx0=" } } From bee4b6801ef78fea32a1f51f66e9f768ca45f1af Mon Sep 17 00:00:00 2001 From: Leonidas <77194479+LeonMueller-OneAndOnly@users.noreply.github.com> Date: Tue, 13 Jan 2026 23:50:19 +0100 Subject: [PATCH 05/87] fix(TUI): make tui work when OPENCODE_SERVER_PASSWORD is set (#8179) --- packages/opencode/src/cli/cmd/tui/worker.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 343a5a3107f8..e63f10ba80c9 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -9,6 +9,7 @@ import { Config } from "@/config/config" import { GlobalBus } from "@/bus/global" import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2" import type { BunWebSocketData } from "hono/bun" +import { Flag } from "@/flag/flag" await Log.init({ print: process.argv.includes("--print-logs"), @@ -50,6 +51,8 @@ const startEventStream = (directory: string) => { const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { const request = new Request(input, init) + const auth = getAuthorizationHeader() + if (auth) request.headers.set("Authorization", auth) return Server.App().fetch(request) }) as typeof globalThis.fetch @@ -95,9 +98,14 @@ startEventStream(process.cwd()) export const rpc = { async fetch(input: { url: string; method: string; headers: Record; body?: string }) { + const headers = { ...input.headers } + const auth = getAuthorizationHeader() + if (auth && !headers["authorization"] && !headers["Authorization"]) { + headers["Authorization"] = auth + } const request = new Request(input.url, { method: input.method, - headers: input.headers, + headers, body: input.body, }) const response = await Server.App().fetch(request) @@ -135,3 +143,10 @@ export const rpc = { } Rpc.listen(rpc) + +function getAuthorizationHeader(): string | undefined { + const password = Flag.OPENCODE_SERVER_PASSWORD + if (!password) return undefined + const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode" + return `Basic ${btoa(`${username}:${password}`)}` +} From 7aa7dd3690db169cb7081f230641aba066d79327 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 13 Jan 2026 22:50:56 +0000 Subject: [PATCH 06/87] chore: generate --- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 1d096cef383d..4a171ae48635 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -25,4 +25,4 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:" } -} \ No newline at end of file +} diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 700e25d8d169..21f0c422d798 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -30,4 +30,4 @@ "publishConfig": { "directory": "dist" } -} \ No newline at end of file +} From eaf18d991576771ccbc3975a6c7a9358b0da8de9 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 13 Jan 2026 17:51:20 -0500 Subject: [PATCH 07/87] wip: black --- infra/console.ts | 2 + packages/console/app/package.json | 2 +- packages/console/app/src/config.ts | 6 - .../app/src/routes/api/black/setup-intent.ts | 30 - .../auth/{callback.ts => [...callback].ts} | 3 +- .../console/app/src/routes/auth/authorize.ts | 12 +- packages/console/app/src/routes/black.css | 79 + .../console/app/src/routes/black/common.tsx | 6 +- .../console/app/src/routes/black/index.tsx | 2 +- .../app/src/routes/black/subscribe.tsx | 244 --- .../app/src/routes/black/subscribe/[plan].tsx | 437 ++++++ .../migrations/0051_jazzy_green_goblin.sql | 1 + .../migrations/0052_aromatic_agent_zero.sql | 1 + .../core/migrations/meta/0051_snapshot.json | 1302 ++++++++++++++++ .../core/migrations/meta/0052_snapshot.json | 1309 +++++++++++++++++ .../core/migrations/meta/_journal.json | 16 +- .../console/core/src/schema/billing.sql.ts | 4 +- packages/console/core/sst-env.d.ts | 4 + packages/console/function/sst-env.d.ts | 4 + packages/console/resource/sst-env.d.ts | 4 + packages/enterprise/sst-env.d.ts | 4 + packages/function/sst-env.d.ts | 4 + sst-env.d.ts | 4 + 23 files changed, 3184 insertions(+), 296 deletions(-) delete mode 100644 packages/console/app/src/routes/api/black/setup-intent.ts rename packages/console/app/src/routes/auth/{callback.ts => [...callback].ts} (91%) delete mode 100644 packages/console/app/src/routes/black/subscribe.tsx create mode 100644 packages/console/app/src/routes/black/subscribe/[plan].tsx create mode 100644 packages/console/core/migrations/0051_jazzy_green_goblin.sql create mode 100644 packages/console/core/migrations/0052_aromatic_agent_zero.sql create mode 100644 packages/console/core/migrations/meta/0051_snapshot.json create mode 100644 packages/console/core/migrations/meta/0052_snapshot.json diff --git a/infra/console.ts b/infra/console.ts index 1368ef202aab..17e4deab6e28 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -122,6 +122,7 @@ const ZEN_MODELS = [ ] const ZEN_BLACK = new sst.Secret("ZEN_BLACK") const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY") +const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY") const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", { properties: { value: auth.url.apply((url) => url!) }, }) @@ -177,6 +178,7 @@ new sst.cloudflare.x.SolidStart("Console", { //VITE_DOCS_URL: web.url.apply((url) => url!), //VITE_API_URL: gateway.url.apply((url) => url!), VITE_AUTH_URL: auth.url.apply((url) => url!), + VITE_STRIPE_PUBLISHABLE_KEY: STRIPE_PUBLISHABLE_KEY.value, }, transform: { server: { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 23171daac887..b70fedead442 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -6,7 +6,7 @@ "scripts": { "typecheck": "tsgo --noEmit", "dev": "vite dev --host 0.0.0.0", - "dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev", + "dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51RtuLNE7fOCwHSD4mewwzFejyytjdGoSDK7CAvhbffwaZnPbNb2rwJICw6LTOXCmWO320fSNXvb5NzI08RZVkAxd00syfqrW7t bun sst shell --stage=dev bun dev", "build": "./script/generate-sitemap.ts && vite build && ../../opencode/script/schema.ts ./.output/public/config.json", "start": "vite start" }, diff --git a/packages/console/app/src/config.ts b/packages/console/app/src/config.ts index 4396e5117198..4ebb2c71abe1 100644 --- a/packages/console/app/src/config.ts +++ b/packages/console/app/src/config.ts @@ -26,10 +26,4 @@ export const config = { commits: "6,500", monthlyUsers: "650,000", }, - - // Stripe - stripe: { - publishableKey: - "pk_live_51OhXSKEclFNgdHcR9dDfYGwQeKuPfKo0IjA5kWBQIXKMFhE8QFd9bYLdPZC6klRKEgEkxJYSKuZg9U3FKHdLnF4300F9qLqMgP", - }, } as const diff --git a/packages/console/app/src/routes/api/black/setup-intent.ts b/packages/console/app/src/routes/api/black/setup-intent.ts deleted file mode 100644 index eb5571616009..000000000000 --- a/packages/console/app/src/routes/api/black/setup-intent.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { APIEvent } from "@solidjs/start/server" -import { Billing } from "@opencode-ai/console-core/billing.js" - -export async function POST(event: APIEvent) { - try { - const body = (await event.request.json()) as { plan: string } - const plan = body.plan - - if (!plan || !["20", "100", "200"].includes(plan)) { - return Response.json({ error: "Invalid plan" }, { status: 400 }) - } - - const amount = parseInt(plan) * 100 - - const intent = await Billing.stripe().setupIntents.create({ - payment_method_types: ["card"], - metadata: { - plan, - amount: amount.toString(), - }, - }) - - return Response.json({ - clientSecret: intent.client_secret, - }) - } catch (error) { - console.error("Error creating setup intent:", error) - return Response.json({ error: "Internal server error" }, { status: 500 }) - } -} diff --git a/packages/console/app/src/routes/auth/callback.ts b/packages/console/app/src/routes/auth/[...callback].ts similarity index 91% rename from packages/console/app/src/routes/auth/callback.ts rename to packages/console/app/src/routes/auth/[...callback].ts index 9b7296791d43..36a9c5194d00 100644 --- a/packages/console/app/src/routes/auth/callback.ts +++ b/packages/console/app/src/routes/auth/[...callback].ts @@ -5,6 +5,7 @@ import { useAuthSession } from "~/context/auth" export async function GET(input: APIEvent) { const url = new URL(input.request.url) + try { const code = url.searchParams.get("code") if (!code) throw new Error("No code found") @@ -27,7 +28,7 @@ export async function GET(input: APIEvent) { current: id, } }) - return redirect("/auth") + return redirect(url.pathname === "/auth/callback" ? "/auth" : url.pathname.replace("/auth/callback", "")) } catch (e: any) { return new Response( JSON.stringify({ diff --git a/packages/console/app/src/routes/auth/authorize.ts b/packages/console/app/src/routes/auth/authorize.ts index 6be94b14695e..0f0651ae36b0 100644 --- a/packages/console/app/src/routes/auth/authorize.ts +++ b/packages/console/app/src/routes/auth/authorize.ts @@ -3,12 +3,8 @@ import { AuthClient } from "~/context/auth" export async function GET(input: APIEvent) { const url = new URL(input.request.url) - // TODO - // input.request.url http://localhost:3001/auth/authorize?continue=/black/subscribe - const result = await AuthClient.authorize( - new URL("/callback/subscribe?foo=bar", input.request.url).toString(), - "code", - ) - // result.url https://auth.frank.dev.opencode.ai/authorize?client_id=app&redirect_uri=http%3A%2F%2Flocalhost%3A3001%2Fauth%2Fcallback&response_type=code&state=0d3fc834-bcbc-42dc-83ab-c25c2c43c7e3 - return Response.redirect(result.url + "&continue=" + url.searchParams.get("continue"), 302) + const cont = url.searchParams.get("continue") ?? "" + const callbackUrl = new URL(`./callback${cont}`, input.request.url) + const result = await AuthClient.authorize(callbackUrl.toString(), "code") + return Response.redirect(result.url, 302) } diff --git a/packages/console/app/src/routes/black.css b/packages/console/app/src/routes/black.css index dfb188ed02af..72bf7a65785b 100644 --- a/packages/console/app/src/routes/black.css +++ b/packages/console/app/src/routes/black.css @@ -460,6 +460,39 @@ font-weight: 400; } + [data-slot="tax-id-section"] { + display: flex; + flex-direction: column; + gap: 8px; + + [data-slot="label"] { + color: rgba(255, 255, 255, 0.59); + font-size: 14px; + } + + [data-slot="input"] { + width: 100%; + height: 44px; + padding: 0 12px; + background: #1a1a1a; + border: 1px solid rgba(255, 255, 255, 0.17); + border-radius: 4px; + color: #ffffff; + font-family: var(--font-mono); + font-size: 14px; + outline: none; + transition: border-color 0.15s ease; + + &::placeholder { + color: rgba(255, 255, 255, 0.39); + } + + &:focus { + border-color: rgba(255, 255, 255, 0.35); + } + } + } + [data-slot="checkout-form"] { display: flex; flex-direction: column; @@ -500,6 +533,52 @@ text-align: center; } + [data-slot="success"] { + display: flex; + flex-direction: column; + gap: 24px; + + [data-slot="title"] { + color: rgba(255, 255, 255, 0.92); + font-size: 18px; + font-weight: 400; + margin: 0; + } + + [data-slot="details"] { + display: flex; + flex-direction: column; + gap: 16px; + + > div { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 16px; + } + + dt { + color: rgba(255, 255, 255, 0.59); + font-size: 14px; + font-weight: 400; + } + + dd { + color: rgba(255, 255, 255, 0.92); + font-size: 14px; + font-weight: 400; + margin: 0; + text-align: right; + } + } + + [data-slot="charge-notice"] { + color: #d4a500; + font-size: 14px; + text-align: left; + } + } + [data-slot="loading"] { display: flex; justify-content: center; diff --git a/packages/console/app/src/routes/black/common.tsx b/packages/console/app/src/routes/black/common.tsx index c1184bd20c61..dd643bbc51f2 100644 --- a/packages/console/app/src/routes/black/common.tsx +++ b/packages/console/app/src/routes/black/common.tsx @@ -1,9 +1,9 @@ import { Match, Switch } from "solid-js" export const plans = [ - { id: "20", amount: 20, multiplier: null }, - { id: "100", amount: 100, multiplier: "6x more usage than Black 20" }, - { id: "200", amount: 200, multiplier: "21x more usage than Black 20" }, + { id: "20", multiplier: null }, + { id: "100", multiplier: "6x more usage than Black 20" }, + { id: "200", multiplier: "21x more usage than Black 20" }, ] as const export type Plan = (typeof plans)[number] diff --git a/packages/console/app/src/routes/black/index.tsx b/packages/console/app/src/routes/black/index.tsx index 2b452c812932..57d27a7939a3 100644 --- a/packages/console/app/src/routes/black/index.tsx +++ b/packages/console/app/src/routes/black/index.tsx @@ -62,7 +62,7 @@ export default function Black() { - + Continue
diff --git a/packages/console/app/src/routes/black/subscribe.tsx b/packages/console/app/src/routes/black/subscribe.tsx deleted file mode 100644 index 00ce19ef691f..000000000000 --- a/packages/console/app/src/routes/black/subscribe.tsx +++ /dev/null @@ -1,244 +0,0 @@ -import { A, createAsync, query, redirect, useSearchParams } from "@solidjs/router" -import { Title } from "@solidjs/meta" -import { createEffect, createSignal, For, onMount, Show } from "solid-js" -import { loadStripe } from "@stripe/stripe-js" -import { Elements, PaymentElement, useStripe, useElements } from "solid-stripe" -import { config } from "~/config" -import { PlanIcon, plans } from "./common" -import { getActor } from "~/context/auth" -import { withActor } from "~/context/auth.withActor" -import { Actor } from "@opencode-ai/console-core/actor.js" -import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js" -import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js" -import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js" -import { createList } from "solid-list" -import { Modal } from "~/component/modal" - -const plansMap = Object.fromEntries(plans.map((p) => [p.id, p])) as Record - -const getWorkspaces = query(async () => { - "use server" - const actor = await getActor() - if (actor.type === "public") throw redirect("/auth/authorize?continue=/black/subscribe") - return withActor(async () => { - return Database.use((tx) => - tx - .select({ - id: WorkspaceTable.id, - name: WorkspaceTable.name, - slug: WorkspaceTable.slug, - }) - .from(UserTable) - .innerJoin(WorkspaceTable, eq(UserTable.workspaceID, WorkspaceTable.id)) - .where( - and( - eq(UserTable.accountID, Actor.account()), - isNull(WorkspaceTable.timeDeleted), - isNull(UserTable.timeDeleted), - ), - ), - ) - }) -}, "black.subscribe.workspaces") - -function CheckoutForm(props: { plan: string; amount: number }) { - const stripe = useStripe() - const elements = useElements() - const [error, setError] = createSignal(null) - const [loading, setLoading] = createSignal(false) - - const handleSubmit = async (e: Event) => { - e.preventDefault() - if (!stripe() || !elements()) return - - setLoading(true) - setError(null) - - const result = await elements()!.submit() - if (result.error) { - setError(result.error.message ?? "An error occurred") - setLoading(false) - return - } - - const { error: confirmError } = await stripe()!.confirmSetup({ - elements: elements()!, - confirmParams: { - return_url: `${window.location.origin}/black/success?plan=${props.plan}`, - }, - }) - - if (confirmError) { - setError(confirmError.message ?? "An error occurred") - } - setLoading(false) - } - - return ( -
- - -

{error()}

-
- -

You will only be charged when your subscription is activated

- - ) -} - -export default function BlackSubscribe() { - const workspaces = createAsync(() => getWorkspaces()) - const [selectedWorkspace, setSelectedWorkspace] = createSignal(null) - - const [params] = useSearchParams() - const plan = (params.plan as string) || "200" - const planData = plansMap[plan] || plansMap["200"] - - const [clientSecret, setClientSecret] = createSignal(null) - const [stripePromise] = createSignal(loadStripe(config.stripe.publishableKey)) - - // Auto-select if only one workspace - createEffect(() => { - const ws = workspaces() - if (ws?.length === 1 && !selectedWorkspace()) { - setSelectedWorkspace(ws[0].id) - } - }) - - // Keyboard navigation for workspace picker - const { active, setActive, onKeyDown } = createList({ - items: () => workspaces()?.map((w) => w.id) ?? [], - initialActive: null, - }) - - const handleSelectWorkspace = (id: string) => { - setSelectedWorkspace(id) - } - - onMount(async () => { - const response = await fetch("/api/black/setup-intent", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ plan }), - }) - const data = await response.json() - if (data.clientSecret) { - setClientSecret(data.clientSecret) - } - }) - - let listRef: HTMLUListElement | undefined - - // Show workspace picker if multiple workspaces and none selected - const showWorkspacePicker = () => { - const ws = workspaces() - return ws && ws.length > 1 && !selectedWorkspace() - } - - return ( - <> - Subscribe to OpenCode Black -
-
-
-

Subscribe to OpenCode Black

-
- -
-

- ${planData.amount} per month - - {planData.multiplier} - -

-
-
-

Add payment method

- -

Loading payment form...

-
- } - > - - - - -
- - {/* Workspace picker modal */} - {}} title="Select a workspace for this plan"> -
-
    { - if (e.key === "Enter" && active()) { - handleSelectWorkspace(active()!) - } else { - onKeyDown(e) - } - }} - > - - {(workspace) => ( -
  • setActive(workspace.id)} - onClick={() => handleSelectWorkspace(workspace.id)} - > - [*] - {workspace.name || workspace.slug} -
  • - )} -
    -
-
-
-

- Prices shown don't include applicable tax · Terms of Service -

-
- - ) -} diff --git a/packages/console/app/src/routes/black/subscribe/[plan].tsx b/packages/console/app/src/routes/black/subscribe/[plan].tsx new file mode 100644 index 000000000000..e0992a1e250d --- /dev/null +++ b/packages/console/app/src/routes/black/subscribe/[plan].tsx @@ -0,0 +1,437 @@ +import { A, action, createAsync, query, redirect, useParams } from "@solidjs/router" +import { Title } from "@solidjs/meta" +import { createEffect, createSignal, For, Show } from "solid-js" +import { type Stripe, type PaymentMethod, loadStripe } from "@stripe/stripe-js" +import { Elements, PaymentElement, useStripe, useElements, AddressElement } from "solid-stripe" +import { PlanIcon, plans } from "../common" +import { getActor, useAuthSession } from "~/context/auth" +import { withActor } from "~/context/auth.withActor" +import { Actor } from "@opencode-ai/console-core/actor.js" +import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js" +import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js" +import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js" +import { createList } from "solid-list" +import { Modal } from "~/component/modal" +import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js" +import { Billing } from "@opencode-ai/console-core/billing.js" + +const plansMap = Object.fromEntries(plans.map((p) => [p.id, p])) as Record +const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY!) + +const getWorkspaces = query(async () => { + "use server" + const actor = await getActor() + if (actor.type === "public") throw redirect("/auth/authorize?continue=/black/subscribe") + return withActor(async () => { + return Database.use((tx) => + tx + .select({ + id: WorkspaceTable.id, + name: WorkspaceTable.name, + slug: WorkspaceTable.slug, + billing: { + customerID: BillingTable.customerID, + paymentMethodID: BillingTable.paymentMethodID, + paymentMethodType: BillingTable.paymentMethodType, + paymentMethodLast4: BillingTable.paymentMethodLast4, + }, + }) + .from(UserTable) + .innerJoin(WorkspaceTable, eq(UserTable.workspaceID, WorkspaceTable.id)) + .innerJoin(BillingTable, eq(WorkspaceTable.id, BillingTable.workspaceID)) + .where( + and( + eq(UserTable.accountID, Actor.account()), + isNull(WorkspaceTable.timeDeleted), + isNull(UserTable.timeDeleted), + ), + ), + ) + }) +}, "black.subscribe.workspaces") + +const createSetupIntent = action(async (input: { plan: string; workspaceID: string }) => { + "use server" + const { plan, workspaceID } = input + + if (!plan || !["20", "100", "200"].includes(plan)) { + return { error: "Invalid plan" } + } + + if (!workspaceID) { + return { error: "Workspace ID is required" } + } + + const actor = await getActor() + if (actor.type === "public") { + return { error: "Unauthorized" } + } + + const session = await useAuthSession() + const account = session.data.account?.[session.data.current ?? ""] + const email = account?.email + + const stripe = Billing.stripe() + + let customerID = await Database.use((tx) => + tx + .select({ customerID: BillingTable.customerID }) + .from(BillingTable) + .where(eq(BillingTable.workspaceID, workspaceID)) + .then((rows) => rows[0].customerID), + ) + if (!customerID) { + const customer = await stripe.customers.create({ + email, + metadata: { + workspaceID, + }, + }) + customerID = customer.id + } + + const intent = await stripe.setupIntents.create({ + customer: customerID, + payment_method_types: ["card"], + metadata: { + workspaceID, + }, + }) + + return { clientSecret: intent.client_secret } +}) + +const bookSubscription = action( + async (input: { + workspaceID: string + paymentMethodID: string + paymentMethodType: string + paymentMethodLast4?: string + }) => { + "use server" + const actor = await getActor() + if (actor.type === "public") { + return { error: "Unauthorized" } + } + + await Database.use((tx) => + tx + .update(BillingTable) + .set({ + paymentMethodID: input.paymentMethodID, + paymentMethodType: input.paymentMethodType, + paymentMethodLast4: input.paymentMethodLast4, + timeSubscriptionBooked: new Date(), + }) + .where(eq(BillingTable.workspaceID, input.workspaceID)), + ) + + return { success: true } + }, +) + +interface SuccessData { + plan: string + paymentMethodType: string + paymentMethodLast4?: string +} + +function PaymentSuccess(props: SuccessData) { + return ( +
+

You're on the OpenCode Black waitlist

+
+
+
Subscription plan
+
OpenCode Black {props.plan}
+
+
+
Amount
+
${props.plan} per month
+
+
+
Payment method
+
+ {props.paymentMethodType}}> + + {props.paymentMethodType} - {props.paymentMethodLast4} + + +
+
+
+
Date joined
+
{new Date().toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
+
+
+

Your card will be charged when your subscription is activated

+
+ ) +} + +function PaymentForm(props: { plan: string; workspaceID: string; onSuccess: (data: SuccessData) => void }) { + const stripe = useStripe() + const elements = useElements() + const [error, setError] = createSignal(null) + const [loading, setLoading] = createSignal(false) + + const handleSubmit = async (e: Event) => { + e.preventDefault() + if (!stripe() || !elements()) return + + setLoading(true) + setError(null) + + const result = await elements()!.submit() + if (result.error) { + setError(result.error.message ?? "An error occurred") + setLoading(false) + return + } + + const { error: confirmError, setupIntent } = await stripe()!.confirmSetup({ + elements: elements()!, + confirmParams: { + expand: ["setup_intent.payment_method"], + payment_method_data: { + allow_redisplay: "always", + }, + }, + redirect: "if_required", + }) + + if (confirmError) { + setError(confirmError.message ?? "An error occurred") + setLoading(false) + return + } + + if (setupIntent?.status === "succeeded") { + const pm = setupIntent.payment_method as PaymentMethod + + await bookSubscription({ + workspaceID: props.workspaceID, + paymentMethodID: pm.id, + paymentMethodType: pm.type, + paymentMethodLast4: pm.card?.last4, + }) + + props.onSuccess({ + plan: props.plan, + paymentMethodType: pm.type, + paymentMethodLast4: pm.card?.last4, + }) + } + + setLoading(false) + } + + return ( +
+ + + +

{error()}

+
+ +

You will only be charged when your subscription is activated

+ + ) +} + +export default function BlackSubscribe() { + const workspaces = createAsync(() => getWorkspaces()) + const [selectedWorkspace, setSelectedWorkspace] = createSignal(null) + const [success, setSuccess] = createSignal(null) + + const params = useParams() + const plan = params.plan || "200" + const planData = plansMap[plan] || plansMap["200"] + + const [clientSecret, setClientSecret] = createSignal(null) + const [setupError, setSetupError] = createSignal(null) + const [stripe, setStripe] = createSignal(null) + + // Resolve stripe promise once + createEffect(() => { + stripePromise.then((s) => { + if (s) setStripe(s) + }) + }) + + // Auto-select if only one workspace + createEffect(() => { + const ws = workspaces() + if (ws?.length === 1 && !selectedWorkspace()) { + setSelectedWorkspace(ws[0].id) + } + }) + + // Fetch setup intent when workspace is selected (unless workspace already has payment method) + createEffect(() => { + const id = selectedWorkspace() + if (!id) return + + const ws = workspaces()?.find((w) => w.id === id) + if (ws?.billing.paymentMethodID) { + setSuccess({ + plan, + paymentMethodType: ws.billing.paymentMethodType!, + paymentMethodLast4: ws.billing.paymentMethodLast4 ?? undefined, + }) + return + } + + setClientSecret(null) + setSetupError(null) + + createSetupIntent({ plan, workspaceID: id }) + .then((data) => { + if (data.clientSecret) { + setClientSecret(data.clientSecret) + } else if (data.error) { + setSetupError(data.error) + } + }) + .catch(() => setSetupError("Failed to initialize payment")) + }) + + // Keyboard navigation for workspace picker + const { active, setActive, onKeyDown } = createList({ + items: () => workspaces()?.map((w) => w.id) ?? [], + initialActive: null, + }) + + const handleSelectWorkspace = (id: string) => { + setSelectedWorkspace(id) + } + + let listRef: HTMLUListElement | undefined + + // Show workspace picker if multiple workspaces and none selected + const showWorkspacePicker = () => { + const ws = workspaces() + return ws && ws.length > 1 && !selectedWorkspace() + } + + return ( + <> + Subscribe to OpenCode Black +
+
+ +
+

Subscribe to OpenCode Black

+
+ +
+

+ ${planData.id} per month + + {planData.multiplier} + +

+
+
+

Payment method

+ + +

{setupError()}

+
+ + +

{selectedWorkspace() ? "Loading payment form..." : "Select a workspace to continue"}

+
+ } + > + + + +
+ + } + > + {(data) => } + +
+ + {/* Workspace picker modal */} + {}} title="Select a workspace for this plan"> +
+
    { + if (e.key === "Enter" && active()) { + handleSelectWorkspace(active()!) + } else { + onKeyDown(e) + } + }} + > + + {(workspace) => ( +
  • setActive(workspace.id)} + onClick={() => handleSelectWorkspace(workspace.id)} + > + [*] + {workspace.name || workspace.slug} +
  • + )} +
    +
+
+
+

+ Prices shown don't include applicable tax · Terms of Service +

+
+ + ) +} diff --git a/packages/console/core/migrations/0051_jazzy_green_goblin.sql b/packages/console/core/migrations/0051_jazzy_green_goblin.sql new file mode 100644 index 000000000000..cadb4a709e59 --- /dev/null +++ b/packages/console/core/migrations/0051_jazzy_green_goblin.sql @@ -0,0 +1 @@ +ALTER TABLE `billing` ADD `time_subscription_booked` timestamp(3); \ No newline at end of file diff --git a/packages/console/core/migrations/0052_aromatic_agent_zero.sql b/packages/console/core/migrations/0052_aromatic_agent_zero.sql new file mode 100644 index 000000000000..c53ba5e2b9c6 --- /dev/null +++ b/packages/console/core/migrations/0052_aromatic_agent_zero.sql @@ -0,0 +1 @@ +ALTER TABLE `billing` ADD `subscription_plan` enum('20','100','200'); \ No newline at end of file diff --git a/packages/console/core/migrations/meta/0051_snapshot.json b/packages/console/core/migrations/meta/0051_snapshot.json new file mode 100644 index 000000000000..38002a28845a --- /dev/null +++ b/packages/console/core/migrations/meta/0051_snapshot.json @@ -0,0 +1,1302 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "14cbf4c8-55f1-4488-956f-56fb5ccb3a5a", + "prevId": "a0d18802-c390-47d4-98f1-d1f83869cf0c", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "account_id_pk": { + "name": "account_id_pk", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "auth": { + "name": "auth", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "enum('email','github','google')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "provider": { + "name": "provider", + "columns": [ + "provider", + "subject" + ], + "isUnique": true + }, + "account_id": { + "name": "account_id", + "columns": [ + "account_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "auth_id_pk": { + "name": "auth_id_pk", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "benchmark": { + "name": "benchmark", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent": { + "name": "agent", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "time_created": { + "name": "time_created", + "columns": [ + "time_created" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "benchmark_id_pk": { + "name": "benchmark_id_pk", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "billing": { + "name": "billing", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_type": { + "name": "payment_method_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_last4": { + "name": "payment_method_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "balance": { + "name": "balance", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_usage": { + "name": "monthly_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_monthly_usage_updated": { + "name": "time_monthly_usage_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload": { + "name": "reload", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_trigger": { + "name": "reload_trigger", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_amount": { + "name": "reload_amount", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_error": { + "name": "reload_error", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_error": { + "name": "time_reload_error", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_locked_till": { + "name": "time_reload_locked_till", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "varchar(28)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_coupon_id": { + "name": "subscription_coupon_id", + "type": "varchar(28)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_subscription_booked": { + "name": "time_subscription_booked", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_customer_id": { + "name": "global_customer_id", + "columns": [ + "customer_id" + ], + "isUnique": true + }, + "global_subscription_id": { + "name": "global_subscription_id", + "columns": [ + "subscription_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "billing_workspace_id_id_pk": { + "name": "billing_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "payment": { + "name": "payment", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoice_id": { + "name": "invoice_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_id": { + "name": "payment_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_refunded": { + "name": "time_refunded", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enrichment": { + "name": "enrichment", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "payment_workspace_id_id_pk": { + "name": "payment_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "subscription": { + "name": "subscription", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rolling_usage": { + "name": "rolling_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fixed_usage": { + "name": "fixed_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_rolling_updated": { + "name": "time_rolling_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_fixed_updated": { + "name": "time_fixed_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspace_user_id": { + "name": "workspace_user_id", + "columns": [ + "workspace_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "subscription_workspace_id_id_pk": { + "name": "subscription_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "usage": { + "name": "usage", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_5m_tokens": { + "name": "cache_write_5m_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_1h_tokens": { + "name": "cache_write_1h_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost": { + "name": "cost", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_id": { + "name": "key_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enrichment": { + "name": "enrichment", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "usage_time_created": { + "name": "usage_time_created", + "columns": [ + "workspace_id", + "time_created" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_workspace_id_id_pk": { + "name": "usage_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ip_rate_limit": { + "name": "ip_rate_limit", + "columns": { + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "interval": { + "name": "interval", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ip_rate_limit_ip_interval_pk": { + "name": "ip_rate_limit_ip_interval_pk", + "columns": [ + "ip", + "interval" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ip": { + "name": "ip", + "columns": { + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage": { + "name": "usage", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ip_ip_pk": { + "name": "ip_ip_pk", + "columns": [ + "ip" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "key": { + "name": "key", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_used": { + "name": "time_used", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_key": { + "name": "global_key", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "key_workspace_id_id_pk": { + "name": "key_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "model": { + "name": "model", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "model_workspace_model": { + "name": "model_workspace_model", + "columns": [ + "workspace_id", + "model" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "model_workspace_id_id_pk": { + "name": "model_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "provider": { + "name": "provider", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_provider": { + "name": "workspace_provider", + "columns": [ + "workspace_id", + "provider" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "provider_workspace_id_id_pk": { + "name": "provider_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_seen": { + "name": "time_seen", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('admin','member')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_usage": { + "name": "monthly_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_monthly_usage_updated": { + "name": "time_monthly_usage_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_account_id": { + "name": "user_account_id", + "columns": [ + "workspace_id", + "account_id" + ], + "isUnique": true + }, + "user_email": { + "name": "user_email", + "columns": [ + "workspace_id", + "email" + ], + "isUnique": true + }, + "global_account_id": { + "name": "global_account_id", + "columns": [ + "account_id" + ], + "isUnique": false + }, + "global_email": { + "name": "global_email", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_workspace_id_id_pk": { + "name": "user_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "workspace": { + "name": "workspace", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "slug": { + "name": "slug", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "workspace_id": { + "name": "workspace_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/console/core/migrations/meta/0052_snapshot.json b/packages/console/core/migrations/meta/0052_snapshot.json new file mode 100644 index 000000000000..b5e1b4b8cbc0 --- /dev/null +++ b/packages/console/core/migrations/meta/0052_snapshot.json @@ -0,0 +1,1309 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "00774acd-a1e5-49c0-b296-cacc9506a566", + "prevId": "14cbf4c8-55f1-4488-956f-56fb5ccb3a5a", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "account_id_pk": { + "name": "account_id_pk", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "auth": { + "name": "auth", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "enum('email','github','google')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "provider": { + "name": "provider", + "columns": [ + "provider", + "subject" + ], + "isUnique": true + }, + "account_id": { + "name": "account_id", + "columns": [ + "account_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "auth_id_pk": { + "name": "auth_id_pk", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "benchmark": { + "name": "benchmark", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent": { + "name": "agent", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "time_created": { + "name": "time_created", + "columns": [ + "time_created" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "benchmark_id_pk": { + "name": "benchmark_id_pk", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "billing": { + "name": "billing", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_type": { + "name": "payment_method_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_last4": { + "name": "payment_method_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "balance": { + "name": "balance", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_usage": { + "name": "monthly_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_monthly_usage_updated": { + "name": "time_monthly_usage_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload": { + "name": "reload", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_trigger": { + "name": "reload_trigger", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_amount": { + "name": "reload_amount", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_error": { + "name": "reload_error", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_error": { + "name": "time_reload_error", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_locked_till": { + "name": "time_reload_locked_till", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "varchar(28)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_coupon_id": { + "name": "subscription_coupon_id", + "type": "varchar(28)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_plan": { + "name": "subscription_plan", + "type": "enum('20','100','200')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_subscription_booked": { + "name": "time_subscription_booked", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_customer_id": { + "name": "global_customer_id", + "columns": [ + "customer_id" + ], + "isUnique": true + }, + "global_subscription_id": { + "name": "global_subscription_id", + "columns": [ + "subscription_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "billing_workspace_id_id_pk": { + "name": "billing_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "payment": { + "name": "payment", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoice_id": { + "name": "invoice_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_id": { + "name": "payment_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_refunded": { + "name": "time_refunded", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enrichment": { + "name": "enrichment", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "payment_workspace_id_id_pk": { + "name": "payment_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "subscription": { + "name": "subscription", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rolling_usage": { + "name": "rolling_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fixed_usage": { + "name": "fixed_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_rolling_updated": { + "name": "time_rolling_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_fixed_updated": { + "name": "time_fixed_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspace_user_id": { + "name": "workspace_user_id", + "columns": [ + "workspace_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "subscription_workspace_id_id_pk": { + "name": "subscription_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "usage": { + "name": "usage", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_5m_tokens": { + "name": "cache_write_5m_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_1h_tokens": { + "name": "cache_write_1h_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost": { + "name": "cost", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_id": { + "name": "key_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enrichment": { + "name": "enrichment", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "usage_time_created": { + "name": "usage_time_created", + "columns": [ + "workspace_id", + "time_created" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_workspace_id_id_pk": { + "name": "usage_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ip_rate_limit": { + "name": "ip_rate_limit", + "columns": { + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "interval": { + "name": "interval", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ip_rate_limit_ip_interval_pk": { + "name": "ip_rate_limit_ip_interval_pk", + "columns": [ + "ip", + "interval" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ip": { + "name": "ip", + "columns": { + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage": { + "name": "usage", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ip_ip_pk": { + "name": "ip_ip_pk", + "columns": [ + "ip" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "key": { + "name": "key", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_used": { + "name": "time_used", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_key": { + "name": "global_key", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "key_workspace_id_id_pk": { + "name": "key_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "model": { + "name": "model", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "model_workspace_model": { + "name": "model_workspace_model", + "columns": [ + "workspace_id", + "model" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "model_workspace_id_id_pk": { + "name": "model_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "provider": { + "name": "provider", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_provider": { + "name": "workspace_provider", + "columns": [ + "workspace_id", + "provider" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "provider_workspace_id_id_pk": { + "name": "provider_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_seen": { + "name": "time_seen", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('admin','member')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_usage": { + "name": "monthly_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_monthly_usage_updated": { + "name": "time_monthly_usage_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_account_id": { + "name": "user_account_id", + "columns": [ + "workspace_id", + "account_id" + ], + "isUnique": true + }, + "user_email": { + "name": "user_email", + "columns": [ + "workspace_id", + "email" + ], + "isUnique": true + }, + "global_account_id": { + "name": "global_account_id", + "columns": [ + "account_id" + ], + "isUnique": false + }, + "global_email": { + "name": "global_email", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_workspace_id_id_pk": { + "name": "user_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "workspace": { + "name": "workspace", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "slug": { + "name": "slug", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "workspace_id": { + "name": "workspace_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/console/core/migrations/meta/_journal.json b/packages/console/core/migrations/meta/_journal.json index 9982daef5eab..019817f91c10 100644 --- a/packages/console/core/migrations/meta/_journal.json +++ b/packages/console/core/migrations/meta/_journal.json @@ -358,6 +358,20 @@ "when": 1767931290031, "tag": "0050_bumpy_mephistopheles", "breakpoints": true + }, + { + "idx": 51, + "version": "5", + "when": 1768341152722, + "tag": "0051_jazzy_green_goblin", + "breakpoints": true + }, + { + "idx": 52, + "version": "5", + "when": 1768343920467, + "tag": "0052_aromatic_agent_zero", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/console/core/src/schema/billing.sql.ts b/packages/console/core/src/schema/billing.sql.ts index 6c2cfcf96f8b..f1300f8498bd 100644 --- a/packages/console/core/src/schema/billing.sql.ts +++ b/packages/console/core/src/schema/billing.sql.ts @@ -1,4 +1,4 @@ -import { bigint, boolean, index, int, json, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core" +import { bigint, boolean, index, int, json, mysqlEnum, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core" import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types" import { workspaceIndexes } from "./workspace.sql" @@ -23,6 +23,8 @@ export const BillingTable = mysqlTable( timeReloadLockedTill: utc("time_reload_locked_till"), subscriptionID: varchar("subscription_id", { length: 28 }), subscriptionCouponID: varchar("subscription_coupon_id", { length: 28 }), + subscriptionPlan: mysqlEnum("subscription_plan", ["20", "100", "200"] as const), + timeSubscriptionBooked: utc("time_subscription_booked"), }, (table) => [ ...workspaceIndexes(table), diff --git a/packages/console/core/sst-env.d.ts b/packages/console/core/sst-env.d.ts index 96fada3e3c00..b8e50a261132 100644 --- a/packages/console/core/sst-env.d.ts +++ b/packages/console/core/sst-env.d.ts @@ -78,6 +78,10 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "STRIPE_PUBLISHABLE_KEY": { + "type": "sst.sst.Secret" + "value": string + } "STRIPE_SECRET_KEY": { "type": "sst.sst.Secret" "value": string diff --git a/packages/console/function/sst-env.d.ts b/packages/console/function/sst-env.d.ts index 96fada3e3c00..b8e50a261132 100644 --- a/packages/console/function/sst-env.d.ts +++ b/packages/console/function/sst-env.d.ts @@ -78,6 +78,10 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "STRIPE_PUBLISHABLE_KEY": { + "type": "sst.sst.Secret" + "value": string + } "STRIPE_SECRET_KEY": { "type": "sst.sst.Secret" "value": string diff --git a/packages/console/resource/sst-env.d.ts b/packages/console/resource/sst-env.d.ts index 96fada3e3c00..b8e50a261132 100644 --- a/packages/console/resource/sst-env.d.ts +++ b/packages/console/resource/sst-env.d.ts @@ -78,6 +78,10 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "STRIPE_PUBLISHABLE_KEY": { + "type": "sst.sst.Secret" + "value": string + } "STRIPE_SECRET_KEY": { "type": "sst.sst.Secret" "value": string diff --git a/packages/enterprise/sst-env.d.ts b/packages/enterprise/sst-env.d.ts index 96fada3e3c00..b8e50a261132 100644 --- a/packages/enterprise/sst-env.d.ts +++ b/packages/enterprise/sst-env.d.ts @@ -78,6 +78,10 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "STRIPE_PUBLISHABLE_KEY": { + "type": "sst.sst.Secret" + "value": string + } "STRIPE_SECRET_KEY": { "type": "sst.sst.Secret" "value": string diff --git a/packages/function/sst-env.d.ts b/packages/function/sst-env.d.ts index 96fada3e3c00..b8e50a261132 100644 --- a/packages/function/sst-env.d.ts +++ b/packages/function/sst-env.d.ts @@ -78,6 +78,10 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "STRIPE_PUBLISHABLE_KEY": { + "type": "sst.sst.Secret" + "value": string + } "STRIPE_SECRET_KEY": { "type": "sst.sst.Secret" "value": string diff --git a/sst-env.d.ts b/sst-env.d.ts index 035a5fc21ddb..3160fc165b86 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -104,6 +104,10 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "STRIPE_PUBLISHABLE_KEY": { + "type": "sst.sst.Secret" + "value": string + } "STRIPE_SECRET_KEY": { "type": "sst.sst.Secret" "value": string From 2f7b2cf603c94a9383533d962b2d48611905ca57 Mon Sep 17 00:00:00 2001 From: Eduard Voiculescu Date: Tue, 13 Jan 2026 17:52:02 -0500 Subject: [PATCH 08/87] docs: Update plan mode restrictions (#8290) --- packages/web/src/content/docs/modes.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/content/docs/modes.mdx b/packages/web/src/content/docs/modes.mdx index ae14c2f32b5f..a31a8223b072 100644 --- a/packages/web/src/content/docs/modes.mdx +++ b/packages/web/src/content/docs/modes.mdx @@ -34,7 +34,7 @@ Build is the **default** mode with all tools enabled. This is the standard mode A restricted mode designed for planning and analysis. In plan mode, the following tools are disabled by default: - `write` - Cannot create new files -- `edit` - Cannot modify existing files +- `edit` - Cannot modify existing files, except for files located at `.opencode/plans/*.md` to detail the plan itself - `patch` - Cannot apply patches - `bash` - Cannot execute shell commands From b8828f2609b7cc2146512566def1359af3a67f47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=8C=9EL=E2=8C=9D?= <151412975+mthezi@users.noreply.github.com> Date: Wed, 14 Jan 2026 07:00:23 +0800 Subject: [PATCH 09/87] docs: add 302ai provider (#8142) --- packages/web/src/content/docs/providers.mdx | 27 +++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index 7af4ab85dba2..effdd8d3c771 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -95,6 +95,33 @@ Don't see a provider here? Submit a PR. --- +### 302.AI + +1. Head over to the [302.AI console](https://302.ai/), create an account, and generate an API key. + +2. Run the `/connect` command and search for **302.AI**. + + ```txt + /connect + ``` + +3. Enter your 302.AI API key. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Run the `/models` command to select a model. + + ```txt + /models + ``` + +--- + ### Amazon Bedrock To use Amazon Bedrock with OpenCode: From 73d5cacc06594398cfa543089012d5ed3b0e9726 Mon Sep 17 00:00:00 2001 From: Daniel M Brasil Date: Tue, 13 Jan 2026 20:31:18 -0300 Subject: [PATCH 10/87] fix: add missing metadata() and ask() defintions to ToolContext type (#8269) --- packages/plugin/src/tool.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/plugin/src/tool.ts b/packages/plugin/src/tool.ts index 37e802ac408e..f759c07d2b57 100644 --- a/packages/plugin/src/tool.ts +++ b/packages/plugin/src/tool.ts @@ -5,6 +5,15 @@ export type ToolContext = { messageID: string agent: string abort: AbortSignal + metadata(input: { title?: string; metadata?: { [key: string]: any } }): void + ask(input: AskInput): Promise +} + +type AskInput = { + permission: string + patterns: string[] + always: string[] + metadata: { [key: string]: any } } export function tool(input: { From 1ff46c75fa72a552f832eec58156f7a7a8e1f128 Mon Sep 17 00:00:00 2001 From: Daniel Sauer <81422812+sauerdaniel@users.noreply.github.com> Date: Wed, 14 Jan 2026 01:03:34 +0100 Subject: [PATCH 11/87] fix(tui): track all timeouts in Footer to prevent memory leak (#8255) --- .../src/cli/cmd/tui/routes/session/footer.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx index d10c49c833fd..8ace2fff3725 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx @@ -25,24 +25,27 @@ export function Footer() { }) onMount(() => { + // Track all timeouts to ensure proper cleanup + const timeouts: ReturnType[] = [] + function tick() { if (connected()) return if (!store.welcome) { setStore("welcome", true) - timeout = setTimeout(() => tick(), 5000) + timeouts.push(setTimeout(() => tick(), 5000)) return } if (store.welcome) { setStore("welcome", false) - timeout = setTimeout(() => tick(), 10_000) + timeouts.push(setTimeout(() => tick(), 10_000)) return } } - let timeout = setTimeout(() => tick(), 10_000) + timeouts.push(setTimeout(() => tick(), 10_000)) onCleanup(() => { - clearTimeout(timeout) + timeouts.forEach(clearTimeout) }) }) From 562f06713155cf5558929d1598ef2c5b908509b3 Mon Sep 17 00:00:00 2001 From: Felix Sanchez Date: Tue, 13 Jan 2026 18:03:45 -0600 Subject: [PATCH 12/87] fix: deduplicate file refs in sent prompts (#8303) --- .../cmd/tui/component/prompt/autocomplete.tsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 4f8f979d5361..5b5358ef0280 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -159,6 +159,28 @@ export function Autocomplete(props: { }) props.setPrompt((draft) => { + if (part.type === "file") { + const existingIndex = draft.parts.findIndex( + (p) => p.type === "file" && "url" in p && p.url === part.url, + ) + if (existingIndex !== -1) { + const existing = draft.parts[existingIndex] + if ( + part.source?.text && + existing && + "source" in existing && + existing.source && + "text" in existing.source && + existing.source.text + ) { + existing.source.text.start = extmarkStart + existing.source.text.end = extmarkEnd + existing.source.text.value = virtualText + } + return + } + } + if (part.type === "file" && part.source?.text) { part.source.text.start = extmarkStart part.source.text.end = extmarkEnd From f242541ef3eb2cfea59d9bd79d2977aec79e7666 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 14 Jan 2026 00:04:24 +0000 Subject: [PATCH 13/87] chore: generate --- .../src/cli/cmd/tui/component/prompt/autocomplete.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 5b5358ef0280..601eb82bc488 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -160,9 +160,7 @@ export function Autocomplete(props: { props.setPrompt((draft) => { if (part.type === "file") { - const existingIndex = draft.parts.findIndex( - (p) => p.type === "file" && "url" in p && p.url === part.url, - ) + const existingIndex = draft.parts.findIndex((p) => p.type === "file" && "url" in p && p.url === part.url) if (existingIndex !== -1) { const existing = draft.parts[existingIndex] if ( From 45fa4eda15f3a066618357e0832b290405a395ca Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 13 Jan 2026 19:15:14 -0500 Subject: [PATCH 14/87] wip: black --- .../console/app/src/routes/black/common.tsx | 1 + .../console/app/src/routes/black/index.tsx | 4 +- .../app/src/routes/black/subscribe/[plan].tsx | 211 +++++++++--------- 3 files changed, 107 insertions(+), 109 deletions(-) diff --git a/packages/console/app/src/routes/black/common.tsx b/packages/console/app/src/routes/black/common.tsx index dd643bbc51f2..950531da1791 100644 --- a/packages/console/app/src/routes/black/common.tsx +++ b/packages/console/app/src/routes/black/common.tsx @@ -6,6 +6,7 @@ export const plans = [ { id: "200", multiplier: "21x more usage than Black 20" }, ] as const +export type PlanID = (typeof plans)[number]["id"] export type Plan = (typeof plans)[number] export function PlanIcon(props: { plan: string }) { diff --git a/packages/console/app/src/routes/black/index.tsx b/packages/console/app/src/routes/black/index.tsx index 57d27a7939a3..5d924a64bc34 100644 --- a/packages/console/app/src/routes/black/index.tsx +++ b/packages/console/app/src/routes/black/index.tsx @@ -22,7 +22,7 @@ export default function Black() {

- ${plan.amount} per month + ${plan.id} per month {plan.multiplier} @@ -43,7 +43,7 @@ export default function Black() {

- ${plan().amount}{" "} + ${plan().id}{" "} per person billed monthly {plan().multiplier} diff --git a/packages/console/app/src/routes/black/subscribe/[plan].tsx b/packages/console/app/src/routes/black/subscribe/[plan].tsx index e0992a1e250d..b78d9138d397 100644 --- a/packages/console/app/src/routes/black/subscribe/[plan].tsx +++ b/packages/console/app/src/routes/black/subscribe/[plan].tsx @@ -1,9 +1,9 @@ -import { A, action, createAsync, query, redirect, useParams } from "@solidjs/router" +import { A, action, createAsync, json, query, redirect, useParams } from "@solidjs/router" import { Title } from "@solidjs/meta" -import { createEffect, createSignal, For, Show } from "solid-js" +import { createEffect, createSignal, For, Match, Show, Switch } from "solid-js" import { type Stripe, type PaymentMethod, loadStripe } from "@stripe/stripe-js" import { Elements, PaymentElement, useStripe, useElements, AddressElement } from "solid-stripe" -import { PlanIcon, plans } from "../common" +import { PlanID, plans } from "../common" import { getActor, useAuthSession } from "~/context/auth" import { withActor } from "~/context/auth.withActor" import { Actor } from "@opencode-ai/console-core/actor.js" @@ -15,7 +15,7 @@ import { Modal } from "~/component/modal" import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js" import { Billing } from "@opencode-ai/console-core/billing.js" -const plansMap = Object.fromEntries(plans.map((p) => [p.id, p])) as Record +const plansMap = Object.fromEntries(plans.map((p) => [p.id, p])) as Record const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY!) const getWorkspaces = query(async () => { @@ -34,6 +34,7 @@ const getWorkspaces = query(async () => { paymentMethodID: BillingTable.paymentMethodID, paymentMethodType: BillingTable.paymentMethodType, paymentMethodLast4: BillingTable.paymentMethodLast4, + subscriptionID: BillingTable.subscriptionID, }, }) .from(UserTable) @@ -50,85 +51,80 @@ const getWorkspaces = query(async () => { }) }, "black.subscribe.workspaces") -const createSetupIntent = action(async (input: { plan: string; workspaceID: string }) => { +const createSetupIntent = async (input: { plan: string; workspaceID: string }) => { "use server" const { plan, workspaceID } = input - if (!plan || !["20", "100", "200"].includes(plan)) { - return { error: "Invalid plan" } - } - - if (!workspaceID) { - return { error: "Workspace ID is required" } - } + if (!plan || !["20", "100", "200"].includes(plan)) return { error: "Invalid plan" } + if (!workspaceID) return { error: "Workspace ID is required" } - const actor = await getActor() - if (actor.type === "public") { - return { error: "Unauthorized" } - } + return withActor(async () => { + const session = await useAuthSession() + const account = session.data.account?.[session.data.current ?? ""] + const email = account?.email - const session = await useAuthSession() - const account = session.data.account?.[session.data.current ?? ""] - const email = account?.email + const customer = await Database.use((tx) => + tx + .select({ + customerID: BillingTable.customerID, + subscriptionID: BillingTable.subscriptionID, + }) + .from(BillingTable) + .where(eq(BillingTable.workspaceID, workspaceID)) + .then((rows) => rows[0]), + ) + if (customer?.subscriptionID) { + return { error: "This workspace already has a subscription" } + } - const stripe = Billing.stripe() + let customerID = customer?.customerID + if (!customerID) { + const customer = await Billing.stripe().customers.create({ + email, + metadata: { + workspaceID, + }, + }) + customerID = customer.id + } - let customerID = await Database.use((tx) => - tx - .select({ customerID: BillingTable.customerID }) - .from(BillingTable) - .where(eq(BillingTable.workspaceID, workspaceID)) - .then((rows) => rows[0].customerID), - ) - if (!customerID) { - const customer = await stripe.customers.create({ - email, + const intent = await Billing.stripe().setupIntents.create({ + customer: customerID, + payment_method_types: ["card"], metadata: { workspaceID, }, }) - customerID = customer.id - } - const intent = await stripe.setupIntents.create({ - customer: customerID, - payment_method_types: ["card"], - metadata: { - workspaceID, - }, - }) - - return { clientSecret: intent.client_secret } -}) - -const bookSubscription = action( - async (input: { - workspaceID: string - paymentMethodID: string - paymentMethodType: string - paymentMethodLast4?: string - }) => { - "use server" - const actor = await getActor() - if (actor.type === "public") { - return { error: "Unauthorized" } - } - - await Database.use((tx) => - tx - .update(BillingTable) - .set({ - paymentMethodID: input.paymentMethodID, - paymentMethodType: input.paymentMethodType, - paymentMethodLast4: input.paymentMethodLast4, - timeSubscriptionBooked: new Date(), - }) - .where(eq(BillingTable.workspaceID, input.workspaceID)), - ) + return { clientSecret: intent.client_secret ?? undefined } + }, workspaceID) +} - return { success: true } - }, -) +const bookSubscription = async (input: { + workspaceID: string + plan: PlanID + paymentMethodID: string + paymentMethodType: string + paymentMethodLast4?: string +}) => { + "use server" + return withActor( + () => + Database.use((tx) => + tx + .update(BillingTable) + .set({ + paymentMethodID: input.paymentMethodID, + paymentMethodType: input.paymentMethodType, + paymentMethodLast4: input.paymentMethodLast4, + subscriptionPlan: input.plan, + timeSubscriptionBooked: new Date(), + }) + .where(eq(BillingTable.workspaceID, input.workspaceID)), + ), + input.workspaceID, + ) +} interface SuccessData { plan: string @@ -136,7 +132,16 @@ interface SuccessData { paymentMethodLast4?: string } -function PaymentSuccess(props: SuccessData) { +function Failure(props: { message: string }) { + return ( +

+

Uh oh, something went wrong

+

{props.message}

+
+ ) +} + +function Success(props: SuccessData) { return (

You're on the OpenCode Black waitlist

@@ -169,10 +174,10 @@ function PaymentSuccess(props: SuccessData) { ) } -function PaymentForm(props: { plan: string; workspaceID: string; onSuccess: (data: SuccessData) => void }) { +function IntentForm(props: { plan: PlanID; workspaceID: string; onSuccess: (data: SuccessData) => void }) { const stripe = useStripe() const elements = useElements() - const [error, setError] = createSignal(null) + const [error, setError] = createSignal(undefined) const [loading, setLoading] = createSignal(false) const handleSubmit = async (e: Event) => { @@ -180,7 +185,7 @@ function PaymentForm(props: { plan: string; workspaceID: string; onSuccess: (dat if (!stripe() || !elements()) return setLoading(true) - setError(null) + setError(undefined) const result = await elements()!.submit() if (result.error) { @@ -211,6 +216,7 @@ function PaymentForm(props: { plan: string; workspaceID: string; onSuccess: (dat await bookSubscription({ workspaceID: props.workspaceID, + plan: props.plan, paymentMethodID: pm.id, paymentMethodType: pm.type, paymentMethodLast4: pm.card?.last4, @@ -243,16 +249,14 @@ function PaymentForm(props: { plan: string; workspaceID: string; onSuccess: (dat export default function BlackSubscribe() { const workspaces = createAsync(() => getWorkspaces()) - const [selectedWorkspace, setSelectedWorkspace] = createSignal(null) - const [success, setSuccess] = createSignal(null) - + const [selectedWorkspace, setSelectedWorkspace] = createSignal(undefined) + const [success, setSuccess] = createSignal(undefined) + const [failure, setFailure] = createSignal(undefined) + const [clientSecret, setClientSecret] = createSignal(undefined) + const [stripe, setStripe] = createSignal(undefined) const params = useParams() - const plan = params.plan || "200" - const planData = plansMap[plan] || plansMap["200"] - - const [clientSecret, setClientSecret] = createSignal(null) - const [setupError, setSetupError] = createSignal(null) - const [stripe, setStripe] = createSignal(null) + const planData = plansMap[(params.plan as PlanID) ?? "20"] ?? plansMap["20"] + const plan = planData.id // Resolve stripe promise once createEffect(() => { @@ -275,27 +279,28 @@ export default function BlackSubscribe() { if (!id) return const ws = workspaces()?.find((w) => w.id === id) - if (ws?.billing.paymentMethodID) { + if (ws?.billing?.subscriptionID) { + setFailure("This workspace already has a subscription") + return + } + if (ws?.billing?.paymentMethodID) { setSuccess({ - plan, + plan: planData.id, paymentMethodType: ws.billing.paymentMethodType!, paymentMethodLast4: ws.billing.paymentMethodLast4 ?? undefined, }) return } - setClientSecret(null) - setSetupError(null) - createSetupIntent({ plan, workspaceID: id }) .then((data) => { - if (data.clientSecret) { + if (data.error) { + setFailure(data.error) + } else if ("clientSecret" in data) { setClientSecret(data.clientSecret) - } else if (data.error) { - setSetupError(data.error) } }) - .catch(() => setSetupError("Failed to initialize payment")) + .catch(() => setFailure("Failed to initialize payment")) }) // Keyboard navigation for workspace picker @@ -321,15 +326,13 @@ export default function BlackSubscribe() { Subscribe to OpenCode Black
- + {(data) => } + {(data) => } + <>

Subscribe to OpenCode Black

-
- -

${planData.id} per month @@ -340,10 +343,6 @@ export default function BlackSubscribe() {

Payment method

- -

{setupError()}

-
- - + - } - > - {(data) => } - + +
{/* Workspace picker modal */} From 82319bbd836762ac485f911409267d004b1599b9 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 13 Jan 2026 19:46:14 -0500 Subject: [PATCH 15/87] wip: black --- .../app/src/routes/black/subscribe/[plan].tsx | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/packages/console/app/src/routes/black/subscribe/[plan].tsx b/packages/console/app/src/routes/black/subscribe/[plan].tsx index b78d9138d397..5e13799dd4d8 100644 --- a/packages/console/app/src/routes/black/subscribe/[plan].tsx +++ b/packages/console/app/src/routes/black/subscribe/[plan].tsx @@ -1,4 +1,4 @@ -import { A, action, createAsync, json, query, redirect, useParams } from "@solidjs/router" +import { A, createAsync, query, redirect, useParams } from "@solidjs/router" import { Title } from "@solidjs/meta" import { createEffect, createSignal, For, Match, Show, Switch } from "solid-js" import { type Stripe, type PaymentMethod, loadStripe } from "@stripe/stripe-js" @@ -35,6 +35,7 @@ const getWorkspaces = query(async () => { paymentMethodType: BillingTable.paymentMethodType, paymentMethodLast4: BillingTable.paymentMethodLast4, subscriptionID: BillingTable.subscriptionID, + timeSubscriptionBooked: BillingTable.timeSubscriptionBooked, }, }) .from(UserTable) @@ -86,6 +87,14 @@ const createSetupIntent = async (input: { plan: string; workspaceID: string }) = }, }) customerID = customer.id + await Database.use((tx) => + tx + .update(BillingTable) + .set({ + customerID, + }) + .where(eq(BillingTable.workspaceID, workspaceID)), + ) } const intent = await Billing.stripe().setupIntents.create({ @@ -135,8 +144,7 @@ interface SuccessData { function Failure(props: { message: string }) { return (
-

Uh oh, something went wrong

-

{props.message}

+

Uh oh! {props.message}

) } @@ -197,7 +205,7 @@ function IntentForm(props: { plan: PlanID; workspaceID: string; onSuccess: (data const { error: confirmError, setupIntent } = await stripe()!.confirmSetup({ elements: elements()!, confirmParams: { - expand: ["setup_intent.payment_method"], + expand: ["payment_method"], payment_method_data: { allow_redisplay: "always", }, @@ -211,6 +219,8 @@ function IntentForm(props: { plan: PlanID; workspaceID: string; onSuccess: (data return } + // TODO + console.log(setupIntent) if (setupIntent?.status === "succeeded") { const pm = setupIntent.payment_method as PaymentMethod @@ -274,7 +284,7 @@ export default function BlackSubscribe() { }) // Fetch setup intent when workspace is selected (unless workspace already has payment method) - createEffect(() => { + createEffect(async () => { const id = selectedWorkspace() if (!id) return @@ -284,6 +294,15 @@ export default function BlackSubscribe() { return } if (ws?.billing?.paymentMethodID) { + if (!ws?.billing?.timeSubscriptionBooked) { + await bookSubscription({ + workspaceID: id, + plan: planData.id, + paymentMethodID: ws.billing.paymentMethodID!, + paymentMethodType: ws.billing.paymentMethodType!, + paymentMethodLast4: ws.billing.paymentMethodLast4 ?? undefined, + }) + } setSuccess({ plan: planData.id, paymentMethodType: ws.billing.paymentMethodType!, @@ -292,15 +311,12 @@ export default function BlackSubscribe() { return } - createSetupIntent({ plan, workspaceID: id }) - .then((data) => { - if (data.error) { - setFailure(data.error) - } else if ("clientSecret" in data) { - setClientSecret(data.clientSecret) - } - }) - .catch(() => setFailure("Failed to initialize payment")) + const result = await createSetupIntent({ plan, workspaceID: id }) + if (result.error) { + setFailure(result.error) + } else if ("clientSecret" in result) { + setClientSecret(result.clientSecret) + } }) // Keyboard navigation for workspace picker From aa522aad62373ffa4c05464164f0ef977afb009e Mon Sep 17 00:00:00 2001 From: opencode Date: Tue, 13 Jan 2026 14:41:54 +0000 Subject: [PATCH 16/87] release: v1.1.17 --- bun.lock | 30 +++++++++++++------------- packages/app/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/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++------ packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 4 ++-- packages/sdk/js/package.json | 4 ++-- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/util/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 18 files changed, 39 insertions(+), 39 deletions(-) diff --git a/bun.lock b/bun.lock index 563c13a33a97..b88195b8374d 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.1.16", + "version": "1.1.17", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -70,7 +70,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.1.16", + "version": "1.1.17", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -101,7 +101,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.1.16", + "version": "1.1.17", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -128,7 +128,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.1.16", + "version": "1.1.17", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -152,7 +152,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.1.16", + "version": "1.1.17", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -176,7 +176,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.1.16", + "version": "1.1.17", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -205,7 +205,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.1.16", + "version": "1.1.17", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -234,7 +234,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.1.16", + "version": "1.1.17", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -250,7 +250,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.1.16", + "version": "1.1.17", "bin": { "opencode": "./bin/opencode", }, @@ -353,7 +353,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.1.16", + "version": "1.1.17", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -373,7 +373,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.1.16", + "version": "1.1.17", "devDependencies": { "@hey-api/openapi-ts": "0.88.1", "@tsconfig/node22": "catalog:", @@ -384,7 +384,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.1.16", + "version": "1.1.17", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -397,7 +397,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.1.16", + "version": "1.1.17", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -437,7 +437,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.1.16", + "version": "1.1.17", "dependencies": { "zod": "catalog:", }, @@ -448,7 +448,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.1.16", + "version": "1.1.17", "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 bef67c82c8c1..305cb7a12125 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.1.16", + "version": "1.1.17", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index b70fedead442..d6463ca9ffb3 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.1.16", + "version": "1.1.17", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index ecfb20007900..4385fd87c44d 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.1.16", + "version": "1.1.17", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 8f3b1ddeef1d..bc9ce254cb02 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.1.16", + "version": "1.1.17", "$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 9572cfde8ef7..b53cd3171a28 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.1.16", + "version": "1.1.17", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 5cf2b20dbe7c..477806f33c51 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.1.16", + "version": "1.1.17", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 7dcdb574d601..259a00b6c688 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.1.16", + "version": "1.1.17", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 6ccac0c10b68..4c4365b75d29 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.1.16" +version = "1.1.17" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.16/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.17/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.16/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.17/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.16/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.17/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.16/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.17/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.16/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.17/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index cc8ae0f18c08..4d109d370c15 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.1.16", + "version": "1.1.17", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 07fee7d730d0..8a3d925c1656 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.1.16", + "version": "1.1.17", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index d1848b4a369f..fc2db6a510dd 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.1.16", + "version": "1.1.17", "type": "module", "license": "MIT", "scripts": { @@ -25,4 +25,4 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:" } -} +} \ No newline at end of file diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index ca24d02aa5cb..98e46ac3c663 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.1.16", + "version": "1.1.17", "type": "module", "license": "MIT", "scripts": { @@ -30,4 +30,4 @@ "publishConfig": { "directory": "dist" } -} +} \ No newline at end of file diff --git a/packages/slack/package.json b/packages/slack/package.json index 1b2d90166295..dfc322fb7402 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.1.16", + "version": "1.1.17", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index b88c747e168c..e9159b73b5ca 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.1.16", + "version": "1.1.17", "type": "module", "license": "MIT", "exports": { diff --git a/packages/util/package.json b/packages/util/package.json index fcd980b4a141..bcbfc0d31b53 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.1.16", + "version": "1.1.17", "private": true, "type": "module", "license": "MIT", diff --git a/packages/web/package.json b/packages/web/package.json index 5a657fe0d745..adafa8540928 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.1.16", + "version": "1.1.17", "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 2bfb97a3348e..dab321752405 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.1.16", + "version": "1.1.17", "publisher": "sst-dev", "repository": { "type": "git", From 43680534dff9ed59830c27e0e0801b5d72307de7 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 13 Jan 2026 09:57:43 -0500 Subject: [PATCH 17/87] add fullscreen view to permission prompt --- AGENTS.md | 6 +- STYLE_GUIDE.md | 21 +- .../cli/cmd/tui/component/prompt/index.tsx | 40 ++-- .../cli/cmd/tui/routes/session/permission.tsx | 212 +++++++++++------- 4 files changed, 162 insertions(+), 117 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 87d59d4c9234..3138f6c5ece4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -- To test opencode in the `packages/opencode` directory you can run `bun dev` -- To regenerate the javascript SDK, run ./packages/sdk/js/script/build.ts +- To test opencode in `packages/opencode`, run `bun dev`. +- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`. - ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. -- the default branch in this repo is `dev` +- The default branch in this repo is `dev`. diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index a46ce221fbf8..52d012fcb97f 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -1,19 +1,16 @@ ## Style Guide -- Try to keep things in one function unless composable or reusable -- AVOID unnecessary destructuring of variables. instead of doing `const { a, b } -= obj` just reference it as obj.a and obj.b. this preserves context -- AVOID `try`/`catch` where possible -- AVOID using `any` type -- PREFER single word variable names where possible -- Use as many bun apis as possible like Bun.file() +- Keep things in one function unless composable or reusable +- Avoid unnecessary destructuring. Instead of `const { a, b } = obj`, use `obj.a` and `obj.b` to preserve context +- Avoid `try`/`catch` where possible +- Avoid using the `any` type +- Prefer single word variable names where possible +- Use Bun APIs when possible, like `Bun.file()` # Avoid let statements -we don't like let statements, especially combined with if/else statements. -prefer const - -This is bad: +We don't like `let` statements, especially combined with if/else statements. +Prefer `const`. Good: @@ -32,7 +29,7 @@ else foo = 2 # Avoid else statements -Prefer early returns or even using `iife` to avoid else statements +Prefer early returns or using an `iife` to avoid else statements. Good: diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index d5e0a0aa2a13..9ad85d08f0e1 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -563,25 +563,27 @@ export function Prompt(props: PromptProps) { })), }) } else { - sdk.client.session.prompt({ - sessionID, - ...selectedModel, - messageID, - agent: local.agent.current().name, - model: selectedModel, - variant, - parts: [ - { - id: Identifier.ascending("part"), - type: "text", - text: inputText, - }, - ...nonTextParts.map((x) => ({ - id: Identifier.ascending("part"), - ...x, - })), - ], - }) + sdk.client.session + .prompt({ + sessionID, + ...selectedModel, + messageID, + agent: local.agent.current().name, + model: selectedModel, + variant, + parts: [ + { + id: Identifier.ascending("part"), + type: "text", + text: inputText, + }, + ...nonTextParts.map((x) => ({ + id: Identifier.ascending("part"), + ...x, + })), + ], + }) + .catch(() => {}) } history.append({ ...store.prompt, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index f5b6badb589a..c95b42260b59 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -1,6 +1,6 @@ import { createStore } from "solid-js/store" import { createMemo, For, Match, Show, Switch } from "solid-js" -import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid" +import { Portal, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import type { TextareaRenderable } from "@opentui/core" import { useKeybind } from "../../context/keybind" import { useTheme, selectedForeground } from "../../context/theme" @@ -11,6 +11,7 @@ import { useSync } from "../../context/sync" import { useTextareaKeybindings } from "../../component/textarea-keybindings" import path from "path" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" +import { Keybind } from "@/util/keybind" import { Locale } from "@/util/locale" type PermissionStage = "permission" | "always" | "reject" @@ -32,7 +33,9 @@ function filetype(input?: string) { } function EditBody(props: { request: PermissionRequest }) { - const { theme, syntax } = useTheme() + const themeState = useTheme() + const theme = themeState.theme + const syntax = themeState.syntax const sync = useSync() const dimensions = useTerminalDimensions() @@ -54,7 +57,7 @@ function EditBody(props: { request: PermissionRequest }) { Edit {normalizePath(filepath())} - + - + ) @@ -172,86 +175,95 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { message: message || undefined, }) }} - onCancel={() => setStore("stage", "permission")} + onCancel={() => { + setStore("stage", "permission") + }} /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - } - options={{ once: "Allow once", always: "Allow always", reject: "Reject" }} - escapeKey="reject" - onSelect={(option) => { - if (option === "always") { - setStore("stage", "always") - return - } - if (option === "reject") { - if (session()?.parentID) { - setStore("stage", "reject") - return + {(() => { + const body = ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + } - sdk.client.permission.reply({ - reply: "reject", - requestID: props.request.id, - }) - } - sdk.client.permission.reply({ - reply: "once", - requestID: props.request.id, - }) - }} - /> + options={{ once: "Allow once", always: "Allow always", reject: "Reject" }} + escapeKey="reject" + fullscreen + onSelect={(option) => { + if (option === "always") { + setStore("stage", "always") + return + } + if (option === "reject") { + if (session()?.parentID) { + setStore("stage", "reject") + return + } + sdk.client.permission.reply({ + reply: "reject", + requestID: props.request.id, + }) + } + sdk.client.permission.reply({ + reply: "once", + requestID: props.request.id, + }) + }} + /> + ) + + return body + })()} ) @@ -327,14 +339,18 @@ function Prompt>(props: { body: JSX.Element options: T escapeKey?: keyof T + fullscreen?: boolean onSelect: (option: keyof T) => void }) { const { theme } = useTheme() const keybind = useKeybind() + const dimensions = useTerminalDimensions() const keys = Object.keys(props.options) as (keyof T)[] const [store, setStore] = createStore({ selected: keys[0], + expanded: false, }) + const diffKey = Keybind.parse("ctrl+f")[0] useKeyboard((evt) => { if (evt.name === "left" || evt.name == "h") { @@ -360,17 +376,36 @@ function Prompt>(props: { evt.preventDefault() props.onSelect(props.escapeKey) } + + if (props.fullscreen && diffKey && Keybind.match(diffKey, keybind.parse(evt))) { + evt.preventDefault() + evt.stopPropagation() + setStore("expanded", (v) => !v) + } }) - return ( + const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen")) + const renderer = useRenderer() + + const content = () => ( - - + + {"△"} {props.title} @@ -403,6 +438,11 @@ function Prompt>(props: { + + + {"ctrl+f"} {hint()} + + {"⇆"} select @@ -413,4 +453,10 @@ function Prompt>(props: { ) + + return ( + {content()}}> + {content()} + + ) } From 15e80fca699234aa8acb4e947c91d35875dee9ec Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 13 Jan 2026 14:58:51 +0000 Subject: [PATCH 18/87] chore: generate --- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index fc2db6a510dd..7b05077161ed 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -25,4 +25,4 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:" } -} \ No newline at end of file +} diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 98e46ac3c663..bd89818d1f02 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -30,4 +30,4 @@ "publishConfig": { "directory": "dist" } -} \ No newline at end of file +} From a761f66a16dc0a2b116477c328d8a49f5c0a947a Mon Sep 17 00:00:00 2001 From: usvimal Date: Tue, 13 Jan 2026 23:39:43 +0800 Subject: [PATCH 19/87] fix(desktop): correct health check endpoint URL to /global/health (#8231) --- packages/desktop/src-tauri/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 75ddb65666cd..0d5b585e87b7 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -198,7 +198,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32, password: &str) -> CommandChild { } async fn check_server_health(url: &str, password: Option<&str>) -> bool { - let health_url = format!("{}/health", url.trim_end_matches('/')); + let health_url = format!("{}/global/health", url.trim_end_matches('/')); let client = reqwest::Client::builder() .timeout(Duration::from_secs(3)) .build(); From 3c9d80d75f8cd506c776b3d2f753d28d6f81d1cd Mon Sep 17 00:00:00 2001 From: Daniel Polito Date: Tue, 13 Jan 2026 12:41:35 -0300 Subject: [PATCH 20/87] feat(desktop): Adding Provider Icons (#8215) --- .../app/src/components/dialog-select-model.tsx | 11 ++++++++++- packages/app/src/components/prompt-input.tsx | 18 ++++++++++++++++-- packages/ui/src/components/button.css | 3 +-- packages/ui/src/components/list.tsx | 12 +++++++++--- packages/ui/src/components/session-turn.tsx | 10 +++++++++- 5 files changed, 45 insertions(+), 9 deletions(-) diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index d54f9369af1a..c614c2d497f3 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -7,6 +7,8 @@ import { Button } from "@opencode-ai/ui/button" import { Tag } from "@opencode-ai/ui/tag" import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import type { IconName } from "@opencode-ai/ui/icons/provider" import { DialogSelectProvider } from "./dialog-select-provider" import { DialogManageModels } from "./dialog-manage-models" @@ -35,6 +37,12 @@ const ModelList: Component<{ filterKeys={["provider.name", "name", "id"]} sortBy={(a, b) => a.name.localeCompare(b.name)} groupBy={(x) => x.provider.name} + groupHeader={(group) => ( +
+ + {group.category} +
+ )} sortGroupsBy={(a, b) => { if (a.category === "Recent" && b.category !== "Recent") return -1 if (b.category === "Recent" && a.category !== "Recent") return 1 @@ -52,7 +60,8 @@ const ModelList: Component<{ }} > {(i) => ( -
+
+ {i.name} Free diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 13f2b00a3755..2be8a21c1d62 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -33,6 +33,8 @@ import { useSync } from "@/context/sync" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import type { IconName } from "@opencode-ai/ui/icons/provider" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" @@ -1560,6 +1562,12 @@ export const PromptInput: Component = (props) => { fallback={ @@ -1569,6 +1577,12 @@ export const PromptInput: Component = (props) => { @@ -1583,10 +1597,10 @@ export const PromptInput: Component = (props) => { > diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css index 800795e87833..c25b89af9936 100644 --- a/packages/ui/src/components/button.css +++ b/packages/ui/src/components/button.css @@ -123,13 +123,13 @@ &[data-size="normal"] { height: 24px; + line-height: 24px; padding: 0 6px; &[data-icon] { padding: 0 12px 0 4px; } font-size: var(--font-size-small); - line-height: var(--line-height-large); gap: 6px; /* text-12-medium */ @@ -137,7 +137,6 @@ font-size: var(--font-size-small); font-style: normal; font-weight: var(--font-weight-medium); - line-height: var(--line-height-large); /* 166.667% */ letter-spacing: var(--letter-spacing-normal); } diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 1283b30232f6..8c92728d7b18 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -10,9 +10,15 @@ export interface ListSearchProps { autofocus?: boolean } +export interface ListGroup { + category: string + items: T[] +} + export interface ListProps extends FilteredListProps { class?: string children: (item: T) => JSX.Element + groupHeader?: (group: ListGroup) => JSX.Element emptyMessage?: string onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void onMove?: (item: T | undefined) => void @@ -116,7 +122,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) setScrollRef, }) - function GroupHeader(props: { category: string }): JSX.Element { + function GroupHeader(groupProps: { category: string; children?: JSX.Element }): JSX.Element { const [stuck, setStuck] = createSignal(false) const [header, setHeader] = createSignal(undefined) @@ -138,7 +144,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) return (
- {props.category} + {groupProps.children ?? groupProps.category}
) } @@ -185,7 +191,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) {(group) => (
- + {props.groupHeader?.(group)}
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 9947578b9085..ae1321bac140 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -22,6 +22,8 @@ import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" import { FileIcon } from "./file-icon" import { Icon } from "./icon" +import { ProviderIcon } from "./provider-icon" +import type { IconName } from "./provider-icons/types" import { IconButton } from "./icon-button" import { Tooltip } from "./tooltip" import { Card } from "./card" @@ -498,7 +500,13 @@ export function SessionTurn( {(msg() as UserMessage).agent} - {(msg() as UserMessage).model?.modelID} + + + {(msg() as UserMessage).model?.modelID} + {(msg() as UserMessage).variant || "default"}
From fcaa041ef99b3b0c1137d63c496b68fb5aa4052e Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 13 Jan 2026 15:42:58 +0000 Subject: [PATCH 21/87] chore: generate --- packages/app/src/components/prompt-input.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 2be8a21c1d62..f1ca3ee888b5 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1563,10 +1563,7 @@ export const PromptInput: Component = (props) => { + ) + }} + + +
+ + + +
+
+ {question()?.question} + {multi() ? " (select all that apply)" : ""} +
+
+ + {(opt, i) => { + const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false + return ( + + ) + }} + + + +
+ setTimeout(() => el.focus(), 0)} + type="text" + data-slot="custom-input" + placeholder="Type your answer..." + value={input()} + onInput={(e) => { + const inputs = [...store.custom] + inputs[store.tab] = e.currentTarget.value + setStore("custom", inputs) + }} + /> + + +
+
+
+
+
+ + +
+
Review your answers
+ + {(q, index) => { + const value = () => store.answers[index()]?.join(", ") ?? "" + const answered = () => Boolean(value()) + return ( +
+ {q.question} + + {answered() ? value() : "(not answered)"} + +
+ ) + }} +
+
+
+ +
+ + + + + + + + + +
+
+ ) +} diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index acab99fe8f62..dcb9adb39c8d 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -1,4 +1,13 @@ -import type { Message, Session, Part, FileDiff, SessionStatus, PermissionRequest } from "@opencode-ai/sdk/v2" +import type { + Message, + Session, + Part, + FileDiff, + SessionStatus, + PermissionRequest, + QuestionRequest, + QuestionAnswer, +} from "@opencode-ai/sdk/v2" import { createSimpleContext } from "./helper" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" @@ -16,6 +25,9 @@ type Data = { permission?: { [sessionID: string]: PermissionRequest[] } + question?: { + [sessionID: string]: QuestionRequest[] + } message: { [sessionID: string]: Message[] } @@ -30,6 +42,10 @@ export type PermissionRespondFn = (input: { response: "once" | "always" | "reject" }) => void +export type QuestionReplyFn = (input: { requestID: string; answers: QuestionAnswer[] }) => void + +export type QuestionRejectFn = (input: { requestID: string }) => void + export type NavigateToSessionFn = (sessionID: string) => void export const { use: useData, provider: DataProvider } = createSimpleContext({ @@ -38,6 +54,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ data: Data directory: string onPermissionRespond?: PermissionRespondFn + onQuestionReply?: QuestionReplyFn + onQuestionReject?: QuestionRejectFn onNavigateToSession?: NavigateToSessionFn }) => { return { @@ -48,6 +66,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return props.directory }, respondToPermission: props.onPermissionRespond, + replyToQuestion: props.onQuestionReply, + rejectQuestion: props.onQuestionReject, navigateToSession: props.onNavigateToSession, } }, From 4dc72669e57979b5b8840e674f25ad6b7c23e215 Mon Sep 17 00:00:00 2001 From: Daniel Sauer <81422812+sauerdaniel@users.noreply.github.com> Date: Tue, 13 Jan 2026 19:38:34 +0100 Subject: [PATCH 29/87] fix(mcp): close existing client before reassignment to prevent leaks (#8253) --- packages/opencode/src/mcp/index.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index aca0c6631520..4e0968391f3f 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -266,6 +266,13 @@ export namespace MCP { status: s.status, } } + // Close existing client if present to prevent memory leaks + const existingClient = s.clients[name] + if (existingClient) { + await existingClient.close().catch((error) => { + log.error("Failed to close existing MCP client", { name, error }) + }) + } s.clients[name] = result.mcpClient s.status[name] = result.status @@ -523,6 +530,13 @@ export namespace MCP { const s = await state() s.status[name] = result.status if (result.mcpClient) { + // Close existing client if present to prevent memory leaks + const existingClient = s.clients[name] + if (existingClient) { + await existingClient.close().catch((error) => { + log.error("Failed to close existing MCP client", { name, error }) + }) + } s.clients[name] = result.mcpClient } } From 9b763372361686b0c47886da800b889821d0c728 Mon Sep 17 00:00:00 2001 From: Daniel Sauer <81422812+sauerdaniel@users.noreply.github.com> Date: Tue, 13 Jan 2026 19:43:16 +0100 Subject: [PATCH 30/87] fix(state): delete key from recordsByKey on instance disposal (#8252) --- packages/opencode/src/project/state.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/project/state.ts b/packages/opencode/src/project/state.ts index c1ac23c5d26c..34a5dbb3e718 100644 --- a/packages/opencode/src/project/state.ts +++ b/packages/opencode/src/project/state.ts @@ -58,6 +58,7 @@ export namespace State { tasks.push(task) } entries.clear() + recordsByKey.delete(key) await Promise.all(tasks) disposalFinished = true log.info("state disposal completed", { key }) From a184714f6714e732960aeb1f047427e979ac3115 Mon Sep 17 00:00:00 2001 From: Zeke Sikelianos Date: Tue, 13 Jan 2026 10:58:09 -0800 Subject: [PATCH 31/87] docs: document ~/.claude/CLAUDE.md compatibility behavior (#8268) --- packages/web/src/content/docs/rules.mdx | 29 ++++++++++++++++++++----- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/web/src/content/docs/rules.mdx b/packages/web/src/content/docs/rules.mdx index 2d02ff47f98f..3a170019a7f7 100644 --- a/packages/web/src/content/docs/rules.mdx +++ b/packages/web/src/content/docs/rules.mdx @@ -3,7 +3,7 @@ title: Rules description: Set custom instructions for opencode. --- -You can provide custom instructions to opencode by creating an `AGENTS.md` file. This is similar to `CLAUDE.md` or Cursor's rules. It contains instructions that will be included in the LLM's context to customize its behavior for your specific project. +You can provide custom instructions to opencode by creating an `AGENTS.md` file. This is similar to Cursor's rules. It contains instructions that will be included in the LLM's context to customize its behavior for your specific project. --- @@ -58,7 +58,7 @@ opencode also supports reading the `AGENTS.md` file from multiple locations. And ### Project -The ones we have seen above, where the `AGENTS.md` is placed in the project root, are project-specific rules. These only apply when you are working in this directory or its sub-directories. +Place an `AGENTS.md` in your project root for project-specific rules. These only apply when you are working in this directory or its sub-directories. ### Global @@ -66,16 +66,33 @@ You can also have global rules in a `~/.config/opencode/AGENTS.md` file. This ge Since this isn't committed to Git or shared with your team, we recommend using this to specify any personal rules that the LLM should follow. +### Claude Code Compatibility + +For users migrating from Claude Code, OpenCode supports Claude Code's file conventions as fallbacks: + +- **Project rules**: `CLAUDE.md` in your project directory (used if no `AGENTS.md` exists) +- **Global rules**: `~/.claude/CLAUDE.md` (used if no `~/.config/opencode/AGENTS.md` exists) +- **Skills**: `~/.claude/skills/` — see [Agent Skills](/docs/skills/) for details + +To disable Claude Code compatibility, set one of these environment variables: + +```bash +export OPENCODE_DISABLE_CLAUDE_CODE=1 # Disable all .claude support +export OPENCODE_DISABLE_CLAUDE_CODE_PROMPT=1 # Disable only ~/.claude/CLAUDE.md +export OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1 # Disable only .claude/skills +``` + --- ## Precedence -So when opencode starts, it looks for: +When opencode starts, it looks for rule files in this order: -1. **Local files** by traversing up from the current directory -2. **Global file** by checking `~/.config/opencode/AGENTS.md` +1. **Local files** by traversing up from the current directory (`AGENTS.md`, `CLAUDE.md`, or `CONTEXT.md`) +2. **Global file** at `~/.config/opencode/AGENTS.md` +3. **Claude Code file** at `~/.claude/CLAUDE.md` (unless disabled) -If you have both global and project-specific rules, opencode will combine them together. +The first matching file wins in each category. For example, if you have both `AGENTS.md` and `CLAUDE.md`, only `AGENTS.md` is used. Similarly, `~/.config/opencode/AGENTS.md` takes precedence over `~/.claude/CLAUDE.md`. --- From a520c4ff9801d96b0c00e6e090023f5dfdb6309b Mon Sep 17 00:00:00 2001 From: Vladimir Glafirov Date: Tue, 13 Jan 2026 20:21:39 +0100 Subject: [PATCH 32/87] feat: Add GitLab Duo Agentic Chat Provider Support (#7333) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Aiden Cline --- bun.lock | 37 +++ packages/opencode/package.json | 1 + packages/opencode/src/plugin/index.ts | 7 +- packages/opencode/src/provider/provider.ts | 41 +++ .../test/provider/amazon-bedrock.test.ts | 7 +- .../opencode/test/provider/gitlab-duo.test.ts | 286 ++++++++++++++++++ packages/web/src/content/docs/providers.mdx | 93 ++++++ 7 files changed, 470 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/test/provider/gitlab-duo.test.ts diff --git a/bun.lock b/bun.lock index 127731a60e66..7a2ebd959637 100644 --- a/bun.lock +++ b/bun.lock @@ -278,6 +278,7 @@ "@ai-sdk/vercel": "1.0.31", "@ai-sdk/xai": "2.0.51", "@clack/prompts": "1.0.0-alpha.1", + "@gitlab/gitlab-ai-provider": "3.1.0", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.25.2", @@ -588,6 +589,10 @@ "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.71.2", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ=="], + + "@anycable/core": ["@anycable/core@0.9.2", "", { "dependencies": { "nanoevents": "^7.0.1" } }, "sha512-x5ZXDcW/N4cxWl93CnbHs/u7qq4793jS2kNPWm+duPrXlrva+ml2ZGT7X9tuOBKzyIHf60zWCdIK7TUgMPAwXA=="], + "@astrojs/cloudflare": ["@astrojs/cloudflare@12.6.3", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.1", "@astrojs/underscore-redirects": "1.0.0", "@cloudflare/workers-types": "^4.20250507.0", "tinyglobby": "^0.2.13", "vite": "^6.3.5", "wrangler": "^4.14.1" }, "peerDependencies": { "astro": "^5.0.0" } }, "sha512-xhJptF5tU2k5eo70nIMyL1Udma0CqmUEnGSlGyFflLqSY82CRQI6nWZ/xZt0ZvmXuErUjIx0YYQNfZsz5CNjLQ=="], "@astrojs/compiler": ["@astrojs/compiler@2.13.0", "", {}, "sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw=="], @@ -908,6 +913,10 @@ "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], + "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.1.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-S0MVXsogrwbOboA/8L0CY5sBXg2HrrO8gdeUeHd9yLZDPsggFD0FzcSuzO5vBO6geUOpruRa8Hqrbb6WWu7Frw=="], + + "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="], "@hey-api/codegen-core": ["@hey-api/codegen-core@0.3.3", "", { "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-vArVDtrvdzFewu1hnjUm4jX1NBITlSCeO81EdWq676MxQbyxsGcDPAgohaSA+Wvr4HjPSvsg2/1s2zYxUtXebg=="], @@ -1602,6 +1611,8 @@ "@smithy/uuid": ["@smithy/uuid@1.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw=="], + "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], + "@solid-primitives/active-element": ["@solid-primitives/active-element@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-9t5K4aR2naVDj950XU8OjnLgOg94a8k5wr6JNOPK+N5ESLsJDq42c1ZP8UKpewi1R+wplMMxiM6OPKRzbxJY7A=="], "@solid-primitives/audio": ["@solid-primitives/audio@1.4.2", "", { "dependencies": { "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-UMD3ORQfI5Ky8yuKPxidDiEazsjv/dsoiKK5yZxLnsgaeNR1Aym3/77h/qT1jBYeXUgj4DX6t7NMpFUSVr14OQ=="], @@ -2322,6 +2333,10 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "engine.io-client": ["engine.io-client@6.6.4", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.18.3", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw=="], + + "engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="], + "enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="], "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], @@ -2544,6 +2559,10 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "graphql": ["graphql@16.12.0", "", {}, "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ=="], + + "graphql-request": ["graphql-request@6.1.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.2.0", "cross-fetch": "^3.1.5" }, "peerDependencies": { "graphql": "14 - 16" } }, "sha512-p+XPfS4q7aIpKVcgmnZKhMNqhltk20hfXtkaIkTfjjmiKMJ5xrt5c743cL03y/K7y1rg3WrIC49xGiEQ4mxdNw=="], + "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], "gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="], @@ -2772,6 +2791,8 @@ "isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="], + "isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="], + "iterate-iterator": ["iterate-iterator@1.0.2", "", {}, "sha512-t91HubM4ZDQ70M9wqp+pcNpu8OyJ9UAtXntT/Bcsvp5tZMnz9vRa+IunKXeI8AnfZMTv0jNuVEmGeLSMjVvfPw=="], "iterate-value": ["iterate-value@1.0.2", "", { "dependencies": { "es-get-iterator": "^1.0.2", "iterate-iterator": "^1.0.1" } }, "sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ=="], @@ -2804,6 +2825,8 @@ "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], @@ -3080,6 +3103,8 @@ "named-placeholders": ["named-placeholders@1.1.3", "", { "dependencies": { "lru-cache": "^7.14.1" } }, "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w=="], + "nanoevents": ["nanoevents@7.0.1", "", {}, "sha512-o6lpKiCxLeijK4hgsqfR6CNToPyRU3keKyyI6uwuHRvpRTbZ0wXw51WRgyldVugZqoJfkGFrjrIenYH3bfEO3Q=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], @@ -3522,6 +3547,10 @@ "smol-toml": ["smol-toml@1.5.2", "", {}, "sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ=="], + "socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="], + + "socket.io-parser": ["socket.io-parser@4.2.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ=="], + "solid-js": ["solid-js@1.9.10", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew=="], "solid-list": ["solid-list@0.3.0", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-t4hx/F/l8Vmq+ib9HtZYl7Z9F1eKxq3eKJTXlvcm7P7yI4Z8O7QSOOEVHb/K6DD7M0RxzVRobK/BS5aSfLRwKg=="], @@ -3688,6 +3717,8 @@ "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], @@ -3880,6 +3911,8 @@ "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + "xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="], + "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], @@ -4030,6 +4063,8 @@ "@expressive-code/plugin-shiki/shiki": ["shiki@3.15.0", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/engine-javascript": "3.15.0", "@shikijs/engine-oniguruma": "3.15.0", "@shikijs/langs": "3.15.0", "@shikijs/themes": "3.15.0", "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kLdkY6iV3dYbtPwS9KXU7mjfmDm25f5m0IPNFnaXO7TBPcvbUOY72PYXSuSqDzwp+vlH/d7MXpHlKO/x+QoLXw=="], + "@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@hey-api/json-schema-ref-parser/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], @@ -4272,6 +4307,8 @@ "editorconfig/minimatch": ["minimatch@9.0.1", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w=="], + "engine.io-client/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "es-get-iterator/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], "esbuild-plugin-copy/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index f2c95d0b3ed1..c0c4e79b69cf 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -70,6 +70,7 @@ "@ai-sdk/vercel": "1.0.31", "@ai-sdk/xai": "2.0.51", "@clack/prompts": "1.0.0-alpha.1", + "@gitlab/gitlab-ai-provider": "3.1.0", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.25.2", diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index b0c9eee2c2ce..8ce6dfd3c3db 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -14,7 +14,11 @@ import { NamedError } from "@opencode-ai/util/error" export namespace Plugin { const log = Log.create({ service: "plugin" }) - const BUILTIN = ["opencode-copilot-auth@0.0.12", "opencode-anthropic-auth@0.0.8"] + const BUILTIN = [ + "opencode-copilot-auth@0.0.12", + "opencode-anthropic-auth@0.0.8", + "@gitlab/opencode-gitlab-auth@1.3.0", + ] // Built-in plugins that are directly imported (not installed from npm) const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin] @@ -46,6 +50,7 @@ export namespace Plugin { if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) { plugins.push(...BUILTIN) } + for (let plugin of plugins) { // ignore old codex plugin since it is supported first party now if (plugin.includes("opencode-openai-codex-auth")) continue diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 3b76b1e029d6..9bde1333ea48 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1,4 +1,6 @@ import z from "zod" +import path from "path" +import os from "os" import fuzzysort from "fuzzysort" import { Config } from "../config/config" import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda" @@ -35,6 +37,7 @@ import { createGateway } from "@ai-sdk/gateway" import { createTogetherAI } from "@ai-sdk/togetherai" import { createPerplexity } from "@ai-sdk/perplexity" import { createVercel } from "@ai-sdk/vercel" +import { createGitLab } from "@gitlab/gitlab-ai-provider" import { ProviderTransform } from "./transform" export namespace Provider { @@ -60,6 +63,7 @@ export namespace Provider { "@ai-sdk/togetherai": createTogetherAI, "@ai-sdk/perplexity": createPerplexity, "@ai-sdk/vercel": createVercel, + "@gitlab/gitlab-ai-provider": createGitLab, // @ts-ignore (TODO: kill this code so we dont have to maintain it) "@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible, } @@ -390,6 +394,43 @@ export namespace Provider { }, } }, + async gitlab(input) { + const instanceUrl = Env.get("GITLAB_INSTANCE_URL") || "https://gitlab.com" + + const auth = await Auth.get(input.id) + const apiKey = await (async () => { + if (auth?.type === "oauth") return auth.access + if (auth?.type === "api") return auth.key + return Env.get("GITLAB_TOKEN") + })() + + const config = await Config.get() + const providerConfig = config.provider?.["gitlab"] + + return { + autoload: !!apiKey, + options: { + instanceUrl, + apiKey, + featureFlags: { + duo_agent_platform_agentic_chat: true, + duo_agent_platform: true, + ...(providerConfig?.options?.featureFlags || {}), + }, + }, + async getModel(sdk: ReturnType, modelID: string, options?: { anthropicModel?: string }) { + const anthropicModel = options?.anthropicModel + return sdk.agenticChat(modelID, { + anthropicModel, + featureFlags: { + duo_agent_platform_agentic_chat: true, + duo_agent_platform: true, + ...(providerConfig?.options?.featureFlags || {}), + }, + }) + }, + } + }, "cloudflare-ai-gateway": async (input) => { const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID") const gateway = Env.get("CLOUDFLARE_GATEWAY_ID") diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts index d10e851391e3..05f5bd01f8fe 100644 --- a/packages/opencode/test/provider/amazon-bedrock.test.ts +++ b/packages/opencode/test/provider/amazon-bedrock.test.ts @@ -9,7 +9,11 @@ import path from "path" mock.module("../../src/bun/index", () => ({ BunProc: { - install: async (pkg: string) => pkg, + install: async (pkg: string, _version?: string) => { + // Return package name without version for mocking + const lastAtIndex = pkg.lastIndexOf("@") + return lastAtIndex > 0 ? pkg.substring(0, lastAtIndex) : pkg + }, run: async () => { throw new Error("BunProc.run should not be called in tests") }, @@ -28,6 +32,7 @@ mock.module("@aws-sdk/credential-providers", () => ({ const mockPlugin = () => ({}) mock.module("opencode-copilot-auth", () => ({ default: mockPlugin })) mock.module("opencode-anthropic-auth", () => ({ default: mockPlugin })) +mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: mockPlugin })) // Import after mocks are set up const { tmpdir } = await import("../fixture/fixture") diff --git a/packages/opencode/test/provider/gitlab-duo.test.ts b/packages/opencode/test/provider/gitlab-duo.test.ts new file mode 100644 index 000000000000..4d5aa9c7461a --- /dev/null +++ b/packages/opencode/test/provider/gitlab-duo.test.ts @@ -0,0 +1,286 @@ +import { test, expect, mock } from "bun:test" +import path from "path" + +// === Mocks === +// These mocks prevent real package installations during tests + +mock.module("../../src/bun/index", () => ({ + BunProc: { + install: async (pkg: string, _version?: string) => { + // Return package name without version for mocking + const lastAtIndex = pkg.lastIndexOf("@") + return lastAtIndex > 0 ? pkg.substring(0, lastAtIndex) : pkg + }, + run: async () => { + throw new Error("BunProc.run should not be called in tests") + }, + which: () => process.execPath, + InstallFailedError: class extends Error {}, + }, +})) + +const mockPlugin = () => ({}) +mock.module("opencode-copilot-auth", () => ({ default: mockPlugin })) +mock.module("opencode-anthropic-auth", () => ({ default: mockPlugin })) +mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: mockPlugin })) + +// Import after mocks are set up +const { tmpdir } = await import("../fixture/fixture") +const { Instance } = await import("../../src/project/instance") +const { Provider } = await import("../../src/provider/provider") +const { Env } = await import("../../src/env") +const { Global } = await import("../../src/global") + +test("GitLab Duo: loads provider with API key from environment", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("GITLAB_TOKEN", "test-gitlab-token") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["gitlab"]).toBeDefined() + expect(providers["gitlab"].key).toBe("test-gitlab-token") + }, + }) +}) + +test("GitLab Duo: config instanceUrl option sets baseURL", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + gitlab: { + options: { + instanceUrl: "https://gitlab.example.com", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("GITLAB_TOKEN", "test-token") + Env.set("GITLAB_INSTANCE_URL", "https://gitlab.example.com") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["gitlab"]).toBeDefined() + expect(providers["gitlab"].options?.instanceUrl).toBe("https://gitlab.example.com") + }, + }) +}) + +test("GitLab Duo: loads with OAuth token from auth.json", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + }), + ) + }, + }) + + const authPath = path.join(Global.Path.data, "auth.json") + await Bun.write( + authPath, + JSON.stringify({ + gitlab: { + type: "oauth", + access: "test-access-token", + refresh: "test-refresh-token", + expires: Date.now() + 3600000, + }, + }), + ) + + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("GITLAB_TOKEN", "") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["gitlab"]).toBeDefined() + }, + }) +}) + +test("GitLab Duo: loads with Personal Access Token from auth.json", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + }), + ) + }, + }) + + const authPath2 = path.join(Global.Path.data, "auth.json") + await Bun.write( + authPath2, + JSON.stringify({ + gitlab: { + type: "api", + key: "glpat-test-pat-token", + }, + }), + ) + + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("GITLAB_TOKEN", "") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["gitlab"]).toBeDefined() + expect(providers["gitlab"].key).toBe("glpat-test-pat-token") + }, + }) +}) + +test("GitLab Duo: supports self-hosted instance configuration", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + gitlab: { + options: { + instanceUrl: "https://gitlab.company.internal", + apiKey: "glpat-internal-token", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("GITLAB_INSTANCE_URL", "https://gitlab.company.internal") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["gitlab"]).toBeDefined() + expect(providers["gitlab"].options?.instanceUrl).toBe("https://gitlab.company.internal") + }, + }) +}) + +test("GitLab Duo: config apiKey takes precedence over environment variable", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + gitlab: { + options: { + apiKey: "config-token", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("GITLAB_TOKEN", "env-token") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["gitlab"]).toBeDefined() + }, + }) +}) + +test("GitLab Duo: supports feature flags configuration", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + gitlab: { + options: { + featureFlags: { + duo_agent_platform_agentic_chat: true, + duo_agent_platform: true, + }, + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("GITLAB_TOKEN", "test-token") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["gitlab"]).toBeDefined() + expect(providers["gitlab"].options?.featureFlags).toBeDefined() + expect(providers["gitlab"].options?.featureFlags?.duo_agent_platform_agentic_chat).toBe(true) + }, + }) +}) + +test("GitLab Duo: has multiple agentic chat models available", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("GITLAB_TOKEN", "test-token") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["gitlab"]).toBeDefined() + const models = Object.keys(providers["gitlab"].models) + expect(models.length).toBeGreaterThan(0) + expect(models).toContain("duo-chat-haiku-4-5") + expect(models).toContain("duo-chat-sonnet-4-5") + expect(models).toContain("duo-chat-opus-4-5") + }, + }) +}) diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index 80c6f89e150a..7af4ab85dba2 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -557,6 +557,99 @@ Cloudflare AI Gateway lets you access models from OpenAI, Anthropic, Workers AI, --- +### GitLab Duo + +GitLab Duo provides AI-powered agentic chat with native tool calling capabilities through GitLab's Anthropic proxy. + +1. Run the `/connect` command and select GitLab. + + ```txt + /connect + ``` + +2. Choose your authentication method: + + ```txt + ┌ Select auth method + │ + │ OAuth (Recommended) + │ Personal Access Token + └ + ``` + + #### Using OAuth (Recommended) + + Select **OAuth** and your browser will open for authorization. + + #### Using Personal Access Token + 1. Go to [GitLab User Settings > Access Tokens](https://gitlab.com/-/user_settings/personal_access_tokens) + 2. Click **Add new token** + 3. Name: `OpenCode`, Scopes: `api` + 4. Copy the token (starts with `glpat-`) + 5. Enter it in the terminal + +3. Run the `/models` command to see available models. + + ```txt + /models + ``` + + Three Claude-based models are available: + - **duo-chat-haiku-4-5** (Default) - Fast responses for quick tasks + - **duo-chat-sonnet-4-5** - Balanced performance for most workflows + - **duo-chat-opus-4-5** - Most capable for complex analysis + +##### Self-Hosted GitLab + +For self-hosted GitLab instances: + +```bash +GITLAB_INSTANCE_URL=https://gitlab.company.com GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx opencode +``` + +Or add to your bash profile: + +```bash title="~/.bash_profile" +export GITLAB_INSTANCE_URL=https://gitlab.company.com +export GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx +``` + +##### Configuration + +Customize through `opencode.json`: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "gitlab": { + "options": { + "instanceUrl": "https://gitlab.com", + "featureFlags": { + "duo_agent_platform_agentic_chat": true, + "duo_agent_platform": true + } + } + } + } +} +``` + +##### GitLab API Tools (Optional) + +To access GitLab tools (merge requests, issues, pipelines, CI/CD, etc.): + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["@gitlab/opencode-gitlab-plugin"] +} +``` + +This plugin provides comprehensive GitLab repository management capabilities including MR reviews, issue tracking, pipeline monitoring, and more. + +--- + ### GitHub Copilot To use your GitHub Copilot subscription with opencode: From 0ddf8e6c6e8ab8d6b0c92cb8332c1900bfd3fbad Mon Sep 17 00:00:00 2001 From: Dillon Mulroy Date: Tue, 13 Jan 2026 14:22:26 -0500 Subject: [PATCH 33/87] fix(cli): mcp auth duplicate radio button icon (#8273) --- packages/opencode/src/cli/cmd/mcp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index cfb54081f677..cdd741fbc755 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -21,7 +21,7 @@ function getAuthStatusIcon(status: MCP.AuthStatus): string { case "expired": return "⚠" case "not_authenticated": - return "○" + return "✗" } } From d831432f930ef23f324c6119d4f2c7f0be7247e8 Mon Sep 17 00:00:00 2001 From: Github Action Date: Tue, 13 Jan 2026 19:22:48 +0000 Subject: [PATCH 34/87] Update Nix flake.lock and x86_64-linux hash --- nix/hashes.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 1dbc18960a13..0bf4aa627331 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,6 +1,6 @@ { "nodeModules": { - "x86_64-linux": "sha256-e3pcCRHba4B5aYIvdteL+PYW2KHO6Ry1qO4DoMn+erE=", - "aarch64-darwin": "sha256-xF9TVBw8aYloNbQLLd19ywwdPIHyS12ktMPhzO+cYx0=" + "x86_64-linux": "sha256-x6A/XT1i3bjakfAj0A1wV4n2s9rpflMDceTeppdP6tE=", + "aarch64-darwin": "sha256-Y3o6lovahSWoG9un/l1qxu7hCmIlZXm2LxOLKNiPQfQ=" } } From d99d1315ee8e49e83d87d6b0006fd4a015540b99 Mon Sep 17 00:00:00 2001 From: Github Action Date: Tue, 13 Jan 2026 19:29:19 +0000 Subject: [PATCH 35/87] Update aarch64-darwin hash --- nix/hashes.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/hashes.json b/nix/hashes.json index 0bf4aa627331..a25b9376e5dd 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,6 +1,6 @@ { "nodeModules": { "x86_64-linux": "sha256-x6A/XT1i3bjakfAj0A1wV4n2s9rpflMDceTeppdP6tE=", - "aarch64-darwin": "sha256-Y3o6lovahSWoG9un/l1qxu7hCmIlZXm2LxOLKNiPQfQ=" + "aarch64-darwin": "sha256-RkamQYbpjJqpHHf76em9lPgeI9k4/kaCf7T+4xHaizY=" } } From 7599396162e8196535f1eb56f7fd9014c70538f0 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 13 Jan 2026 13:33:58 -0600 Subject: [PATCH 36/87] tweak: ensure external dir and bash tool invocations render workdir details --- .../src/cli/cmd/tui/routes/session/index.tsx | 29 ++++++++++++++++++- .../cli/cmd/tui/routes/session/permission.tsx | 18 +++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 10e340d7f8f5..f87b811ae87c 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -69,6 +69,7 @@ import { Footer } from "./footer.tsx" import { usePromptRef } from "../../context/prompt" import { useExit } from "../../context/exit" import { Filesystem } from "@/util/filesystem" +import { Global } from "@/global" import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" @@ -1525,6 +1526,7 @@ function BlockTool(props: { title: string; children: JSX.Element; onClick?: () = function Bash(props: ToolProps) { const { theme } = useTheme() + const sync = useSync() const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? "")) const [expanded, setExpanded] = createSignal(false) const lines = createMemo(() => output().split("\n")) @@ -1534,11 +1536,36 @@ function Bash(props: ToolProps) { return [...lines().slice(0, 10), "…"].join("\n") }) + const workdirDisplay = createMemo(() => { + const workdir = props.input.workdir + if (!workdir || workdir === ".") return undefined + + const base = sync.data.path.directory + if (!base) return undefined + + const absolute = path.resolve(base, workdir) + if (absolute === base) return undefined + + const home = Global.Path.home + if (!home) return absolute + + const match = absolute === home || absolute.startsWith(home + path.sep) + return match ? absolute.replace(home, "~") : absolute + }) + + const title = createMemo(() => { + const desc = props.input.description ?? "Shell" + const wd = workdirDisplay() + if (!wd) return `# ${desc}` + if (desc.includes(wd)) return `# ${desc}` + return `# ${desc} in ${wd}` + }) + return ( setExpanded((prev) => !prev) : undefined} > diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index c95b42260b59..9cde65d2e63d 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -226,7 +226,23 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { - + {(() => { + const meta = props.request.metadata ?? {} + const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined + const filepath = typeof meta["filepath"] === "string" ? meta["filepath"] : undefined + const pattern = props.request.patterns?.[0] + const derived = + typeof pattern === "string" + ? pattern.includes("*") + ? path.dirname(pattern) + : pattern + : undefined + + const raw = parent ?? filepath ?? derived + const dir = normalizePath(raw) + + return + })()} From 4eb6b5750351762e4602454be657ae5b8f72b30b Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 13 Jan 2026 13:52:09 -0600 Subject: [PATCH 37/87] tweak: external dir permission rendering in tui --- .../cli/cmd/tui/routes/session/permission.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 9cde65d2e63d..eab2adb100c4 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -13,15 +13,26 @@ import path from "path" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { Keybind } from "@/util/keybind" import { Locale } from "@/util/locale" +import { Global } from "@/global" type PermissionStage = "permission" | "always" | "reject" function normalizePath(input?: string) { if (!input) return "" - if (path.isAbsolute(input)) { - return path.relative(process.cwd(), input) || "." + + const cwd = process.cwd() + const home = Global.Path.home + const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input) + const relative = path.relative(cwd, absolute) + + if (!relative) return "." + if (!relative.startsWith("..")) return relative + + // outside cwd - use ~ or absolute + if (home && (absolute === home || absolute.startsWith(home + path.sep))) { + return absolute.replace(home, "~") } - return input + return absolute } function filetype(input?: string) { From 16b2bfa8ef0b3ea9bbe9d469bd48d47eaf108955 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 13 Jan 2026 13:57:34 -0600 Subject: [PATCH 38/87] add family to gpt 5.2 codex in codex plugin --- packages/opencode/src/plugin/codex.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index 4e2b283795d5..91e66197fc47 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -387,6 +387,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { headers: {}, release_date: "2025-12-18", variants: {} as Record>, + family: "gpt-codex", } model.variants = ProviderTransform.variants(model) provider.models["gpt-5.2-codex"] = model From 1fccb3bda443d37e8f940c85caeb10c55c44425b Mon Sep 17 00:00:00 2001 From: Joe Harrison <22684038+josephbharrison@users.noreply.github.com> Date: Tue, 13 Jan 2026 15:06:38 -0500 Subject: [PATCH 39/87] fix(prompt-input): handle Shift+Enter before IME check to prevent stuck state (#8275) --- packages/app/src/components/prompt-input.tsx | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index f1ca3ee888b5..2f85652a93ee 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -364,6 +364,12 @@ export const PromptInput: Component = (props) => { if (!isFocused()) setStore("popover", null) }) + // Safety: reset composing state on focus change to prevent stuck state + // This handles edge cases where compositionend event may not fire + createEffect(() => { + if (!isFocused()) setComposing(false) + }) + type AtOption = { type: "agent"; name: string; display: string } | { type: "file"; path: string; display: string } const agentList = createMemo(() => @@ -881,6 +887,14 @@ export const PromptInput: Component = (props) => { } } + // Handle Shift+Enter BEFORE IME check - Shift+Enter is never used for IME input + // and should always insert a newline regardless of composition state + if (event.key === "Enter" && event.shiftKey) { + addPart({ type: "text", content: "\n", start: 0, end: 0 }) + event.preventDefault() + return + } + if (event.key === "Enter" && isImeComposing(event)) { return } @@ -944,11 +958,7 @@ export const PromptInput: Component = (props) => { return } - if (event.key === "Enter" && event.shiftKey) { - addPart({ type: "text", content: "\n", start: 0, end: 0 }) - event.preventDefault() - return - } + // Note: Shift+Enter is handled earlier, before IME check if (event.key === "Enter" && !event.shiftKey) { handleSubmit(event) } From 3997d3f2d7d663f5a8f84a9e26c17d4913304acb Mon Sep 17 00:00:00 2001 From: Dax Date: Tue, 13 Jan 2026 15:55:48 -0500 Subject: [PATCH 40/87] feat: add plan mode with enter/exit tools (#8281) --- .../plans/1768330644696-gentle-harbor.md | 320 ++++++++++++++++++ packages/opencode/src/agent/agent.ts | 6 +- .../src/cli/cmd/tui/routes/session/index.tsx | 17 + .../cli/cmd/tui/routes/session/question.tsx | 61 ++-- packages/opencode/src/flag/flag.ts | 1 + packages/opencode/src/question/index.ts | 1 + packages/opencode/src/session/index.ts | 9 + packages/opencode/src/session/prompt.ts | 143 +++++++- .../src/session/prompt/build-switch.txt | 2 + packages/opencode/src/tool/plan-enter.txt | 14 + packages/opencode/src/tool/plan-exit.txt | 13 + packages/opencode/src/tool/plan.ts | 130 +++++++ packages/opencode/src/tool/registry.ts | 2 + packages/opencode/test/util/lock.test.ts | 72 ++++ packages/sdk/js/src/v2/gen/types.gen.ts | 5 + packages/util/src/slug.ts | 74 ++++ 16 files changed, 824 insertions(+), 46 deletions(-) create mode 100644 .opencode/plans/1768330644696-gentle-harbor.md create mode 100644 packages/opencode/src/tool/plan-enter.txt create mode 100644 packages/opencode/src/tool/plan-exit.txt create mode 100644 packages/opencode/src/tool/plan.ts create mode 100644 packages/opencode/test/util/lock.test.ts create mode 100644 packages/util/src/slug.ts diff --git a/.opencode/plans/1768330644696-gentle-harbor.md b/.opencode/plans/1768330644696-gentle-harbor.md new file mode 100644 index 000000000000..9e3e668b4aba --- /dev/null +++ b/.opencode/plans/1768330644696-gentle-harbor.md @@ -0,0 +1,320 @@ +# Plan: Implement enter_plan and exit_plan Tools + +## Summary + +The plan mode workflow in `prompt.ts` references `exit_plan` tool that doesn't exist. We need to implement two tools: + +1. **`exit_plan`** - Called when the AI finishes planning; uses the Question module to ask the user if they want to switch to build mode (yes/no). **Only available in plan mode.** If user says yes, creates a synthetic user message with the "build" agent to trigger the mode switch in the loop. +2. **`enter_plan`** - Called to enter plan mode. **Only available in build mode.** If user says yes, creates a synthetic user message with the "plan" agent. + +## Key Insight: How Mode Switching Works + +Looking at `prompt.ts:455-478`, the session loop determines the current agent from the last user message's `agent` field (line 510: `const agent = await Agent.get(lastUser.agent)`). + +To switch modes, we need to: + +1. Ask the user for confirmation +2. If confirmed, create a synthetic user message with the **new agent** specified +3. The loop will pick up this new user message and use the new agent + +## Files to Modify + +| File | Action | +| ------------------------------------------ | --------------------------------------------------------------- | +| `packages/opencode/src/tool/plan.ts` | **CREATE** - New file with both tools | +| `packages/opencode/src/tool/exitplan.txt` | **CREATE** - Description for exit_plan tool | +| `packages/opencode/src/tool/enterplan.txt` | **CREATE** - Description for enter_plan tool | +| `packages/opencode/src/tool/registry.ts` | **MODIFY** - Register the new tools | +| `packages/opencode/src/agent/agent.ts` | **MODIFY** - Add permission rules to restrict tool availability | + +## Implementation Details + +### 1. Create `packages/opencode/src/tool/plan.ts` + +```typescript +import z from "zod" +import { Tool } from "./tool" +import { Question } from "../question" +import { Session } from "../session" +import { MessageV2 } from "../session/message-v2" +import { Identifier } from "../id/id" +import { Provider } from "../provider/provider" +import EXIT_DESCRIPTION from "./exitplan.txt" +import ENTER_DESCRIPTION from "./enterplan.txt" + +export const ExitPlanTool = Tool.define("exit_plan", { + description: EXIT_DESCRIPTION, + parameters: z.object({}), + async execute(_params, ctx) { + const answers = await Question.ask({ + sessionID: ctx.sessionID, + questions: [ + { + question: "Planning is complete. Would you like to switch to build mode and start implementing?", + header: "Build Mode", + options: [ + { label: "Yes", description: "Switch to build mode and start implementing the plan" }, + { label: "No", description: "Stay in plan mode to continue refining the plan" }, + ], + }, + ], + tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, + }) + + const answer = answers[0]?.[0] + const shouldSwitch = answer === "Yes" + + // If user wants to switch, create a synthetic user message with the new agent + if (shouldSwitch) { + // Get model from the last user message in the session + const model = await getLastModel(ctx.sessionID) + + const userMsg: MessageV2.User = { + id: Identifier.ascending("message"), + sessionID: ctx.sessionID, + role: "user", + time: { + created: Date.now(), + }, + agent: "build", // Switch to build agent + model, + } + await Session.updateMessage(userMsg) + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: userMsg.id, + sessionID: ctx.sessionID, + type: "text", + text: "User has approved the plan. Switch to build mode and begin implementing the plan.", + synthetic: true, + } satisfies MessageV2.TextPart) + } + + return { + title: shouldSwitch ? "Switching to build mode" : "Staying in plan mode", + output: shouldSwitch + ? "User confirmed to switch to build mode. A new message has been created to switch you to build mode. Begin implementing the plan." + : "User chose to stay in plan mode. Continue refining the plan or address any concerns.", + metadata: { + switchToBuild: shouldSwitch, + answer, + }, + } + }, +}) + +export const EnterPlanTool = Tool.define("enter_plan", { + description: ENTER_DESCRIPTION, + parameters: z.object({}), + async execute(_params, ctx) { + const answers = await Question.ask({ + sessionID: ctx.sessionID, + questions: [ + { + question: + "Would you like to switch to plan mode? In plan mode, the AI will only research and create a plan without making changes.", + header: "Plan Mode", + options: [ + { label: "Yes", description: "Switch to plan mode for research and planning" }, + { label: "No", description: "Stay in build mode to continue making changes" }, + ], + }, + ], + tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, + }) + + const answer = answers[0]?.[0] + const shouldSwitch = answer === "Yes" + + // If user wants to switch, create a synthetic user message with the new agent + if (shouldSwitch) { + const model = await getLastModel(ctx.sessionID) + + const userMsg: MessageV2.User = { + id: Identifier.ascending("message"), + sessionID: ctx.sessionID, + role: "user", + time: { + created: Date.now(), + }, + agent: "plan", // Switch to plan agent + model, + } + await Session.updateMessage(userMsg) + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: userMsg.id, + sessionID: ctx.sessionID, + type: "text", + text: "User has requested to enter plan mode. Switch to plan mode and begin planning.", + synthetic: true, + } satisfies MessageV2.TextPart) + } + + return { + title: shouldSwitch ? "Switching to plan mode" : "Staying in build mode", + output: shouldSwitch + ? "User confirmed to switch to plan mode. A new message has been created to switch you to plan mode. Begin planning." + : "User chose to stay in build mode. Continue with the current task.", + metadata: { + switchToPlan: shouldSwitch, + answer, + }, + } + }, +}) + +// Helper to get the model from the last user message +async function getLastModel(sessionID: string) { + for await (const item of MessageV2.stream(sessionID)) { + if (item.info.role === "user" && item.info.model) return item.info.model + } + return Provider.defaultModel() +} +``` + +### 2. Create `packages/opencode/src/tool/exitplan.txt` + +``` +Use this tool when you have completed the planning phase and are ready to exit plan mode. + +This tool will ask the user if they want to switch to build mode to start implementing the plan. + +Call this tool: +- After you have written a complete plan to the plan file +- After you have clarified any questions with the user +- When you are confident the plan is ready for implementation + +Do NOT call this tool: +- Before you have created or finalized the plan +- If you still have unanswered questions about the implementation +- If the user has indicated they want to continue planning +``` + +### 3. Create `packages/opencode/src/tool/enterplan.txt` + +``` +Use this tool to suggest entering plan mode when the user's request would benefit from planning before implementation. + +This tool will ask the user if they want to switch to plan mode. + +Call this tool when: +- The user's request is complex and would benefit from planning first +- You want to research and design before making changes +- The task involves multiple files or significant architectural decisions + +Do NOT call this tool: +- For simple, straightforward tasks +- When the user explicitly wants immediate implementation +- When already in plan mode +``` + +### 4. Modify `packages/opencode/src/tool/registry.ts` + +Add import and register tools: + +```typescript +// Add import at top (around line 27) +import { ExitPlanTool, EnterPlanTool } from "./plan" + +// Add to the all() function return array (around line 110-112) +return [ + // ... existing tools + ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), + ...(config.experimental?.batch_tool === true ? [BatchTool] : []), + ExitPlanTool, + EnterPlanTool, + ...custom, +] +``` + +### 5. Modify `packages/opencode/src/agent/agent.ts` + +Add permission rules to control which agent can use which tool: + +**In the `defaults` ruleset (around line 47-63):** + +```typescript +const defaults = PermissionNext.fromConfig({ + "*": "allow", + doom_loop: "ask", + // Add these new defaults - both denied by default + exit_plan: "deny", + enter_plan: "deny", + external_directory: { + // ... existing + }, + // ... rest of existing defaults +}) +``` + +**In the `build` agent (around line 67-79):** + +```typescript +build: { + name: "build", + options: {}, + permission: PermissionNext.merge( + defaults, + PermissionNext.fromConfig({ + question: "allow", + enter_plan: "allow", // Allow build agent to suggest plan mode + }), + user, + ), + mode: "primary", + native: true, +}, +``` + +**In the `plan` agent (around line 80-96):** + +```typescript +plan: { + name: "plan", + options: {}, + permission: PermissionNext.merge( + defaults, + PermissionNext.fromConfig({ + question: "allow", + exit_plan: "allow", // Allow plan agent to exit plan mode + edit: { + "*": "deny", + ".opencode/plans/*.md": "allow", + }, + }), + user, + ), + mode: "primary", + native: true, +}, +``` + +## Design Decisions + +1. **Synthetic user message for mode switching**: When the user confirms a mode switch, a synthetic user message is created with the new agent specified. The loop picks this up on the next iteration and switches to the new agent. This follows the existing pattern in `prompt.ts:455-478`. + +2. **Permission-based tool availability**: Uses the existing permission system to control which tools are available to which agents. `exit_plan` is only available in plan mode, `enter_plan` only in build mode. + +3. **Question-based confirmation**: Both tools use the Question module for consistent UX. + +4. **Model preservation**: The synthetic user message preserves the model from the previous user message. + +## Verification + +1. Run `bun dev` in `packages/opencode` +2. Start a session in build mode + - Verify `exit_plan` is NOT available (denied by permission) + - Verify `enter_plan` IS available +3. Call `enter_plan` in build mode + - Verify the question prompt appears + - Select "Yes" and verify: + - A synthetic user message is created with `agent: "plan"` + - The next assistant response is from the plan agent + - The plan mode system reminder appears +4. In plan mode, call `exit_plan` + - Verify the question prompt appears + - Select "Yes" and verify: + - A synthetic user message is created with `agent: "build"` + - The next assistant response is from the build agent +5. Test "No" responses - verify no mode switch occurs diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index ea9d3e3ba1fd..6847d29abe5e 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -53,6 +53,8 @@ export namespace Agent { [Truncate.GLOB]: "allow", }, question: "deny", + plan_enter: "deny", + plan_exit: "deny", // mirrors github.com/github/gitignore Node.gitignore pattern for .env files read: { "*": "allow", @@ -71,6 +73,7 @@ export namespace Agent { defaults, PermissionNext.fromConfig({ question: "allow", + plan_enter: "allow", }), user, ), @@ -84,9 +87,10 @@ export namespace Agent { defaults, PermissionNext.fromConfig({ question: "allow", + plan_exit: "allow", edit: { "*": "deny", - ".opencode/plan/*.md": "allow", + ".opencode/plans/*.md": "allow", }, }), user, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index f87b811ae87c..b6916bc5a58c 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -196,6 +196,23 @@ export function Session() { } }) + let lastSwitch: string | undefined = undefined + sdk.event.on("message.part.updated", (evt) => { + const part = evt.properties.part + if (part.type !== "tool") return + if (part.sessionID !== route.sessionID) return + if (part.state.status !== "completed") return + if (part.id === lastSwitch) return + + if (part.tool === "plan_exit") { + local.agent.set("build") + lastSwitch = part.id + } else if (part.tool === "plan_enter") { + local.agent.set("plan") + lastSwitch = part.id + } + }) + let scroll: ScrollBoxRenderable let prompt: PromptRef const keybind = useKeybind() diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx index ccc0e9b12506..5e8ce23807c1 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -32,7 +32,8 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { const question = createMemo(() => questions()[store.tab]) const confirm = createMemo(() => !single() && store.tab === questions().length) const options = createMemo(() => question()?.options ?? []) - const other = createMemo(() => store.selected === options().length) + const custom = createMemo(() => question()?.custom !== false) + const other = createMemo(() => custom() && store.selected === options().length) const input = createMemo(() => store.custom[store.tab] ?? "") const multi = createMemo(() => question()?.multiple === true) const customPicked = createMemo(() => { @@ -203,7 +204,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { } } else { const opts = options() - const total = opts.length + 1 // options + "Other" + const total = opts.length + (custom() ? 1 : 0) if (evt.name === "up" || evt.name === "k") { evt.preventDefault() @@ -298,35 +299,37 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { ) }} - moveTo(options().length)} onMouseUp={() => selectOption()}> - - - - {options().length + 1}. Type your own answer - + + moveTo(options().length)} onMouseUp={() => selectOption()}> + + + + {options().length + 1}. Type your own answer + + + {customPicked() ? "✓" : ""} - {customPicked() ? "✓" : ""} + + +