Compare commits

..
Author SHA1 Message Date
Aiden Cline a0fd15e9e4 rm dead code 2026-01-09 17:41:36 -06:00
Aiden Cline 4f2514d9f5 rm comment 2026-01-09 17:40:32 -06:00
Aiden Cline 5d9801f206 rm unnecessary code 2026-01-09 17:39:30 -06:00
Aiden Cline a300cd6465 fix: model list 2026-01-09 17:06:13 -06:00
Aiden Cline 399699469b workin 2026-01-09 16:43:11 -06:00
Aiden Cline cacf9df24c filter out old plugin 2026-01-09 16:35:00 -06:00
Aiden Cline 2844086752 wip: codex 2026-01-09 16:27:44 -06:00
Ariane Emory 75df5040ea fix: report config errors instead of silent termination (#7522) 2026-01-09 14:21:01 -06:00
Github Action f2b2940298 Update Nix flake.lock and hashes 2026-01-09 19:20:11 +00:00
Sebastian Herrlinger 22f51c6b47 upgrade opentui to v0.1.72 2026-01-09 20:18:34 +01:00
Aiden Cline de286b08f6 ignore: bump plugin version 2026-01-09 12:33:51 -06:00
adamelmore 0f2124db32 fix(app): no inline js 2026-01-09 11:57:31 -06:00
Ravi Kumar e30a159264 fix(cli): enable API key prompt for Bedrock in auth login (#7332) 2026-01-09 11:56:19 -06:00
ryanwyler a5edf3a311 fix: resolve broken forked sessions with compactions due to missing parent-child message references (#6445) 2026-01-09 11:42:55 -06:00
Aiden Cline 7c2907cbb4 ci: fix failing pipeline 2026-01-09 11:38:01 -06:00
GitHub Action bce9dc040f chore: generate 2026-01-09 17:03:51 +00:00
adamelmore 44297ffe78 fix(app): break words in user message 2026-01-09 11:03:11 -06:00
adamelmore cbb3141130 fix(app): no custom url param 2026-01-09 11:03:11 -06:00
Frank 18cf4df6c6 wip: zen 2026-01-09 11:52:31 -05:00
Aiden Cline f3e8a275b8 docs: update brew formula references 2026-01-09 10:39:59 -06:00
30 changed files with 802 additions and 369 deletions
+2 -2
View File
@@ -105,7 +105,7 @@ jobs:
query($owner: String!, $repo: String!, $number: Int!) { query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) { repository(owner: $owner, name: $repo) {
pullRequest(number: $number) { pullRequest(number: $number) {
issuesReferences(first: 1) { closingIssuesReferences(first: 1) {
totalCount totalCount
} }
} }
@@ -119,7 +119,7 @@ jobs:
number: pr.number number: pr.number
}); });
const linkedIssues = result.repository.pullRequest.issuesReferences.totalCount; const linkedIssues = result.repository.pullRequest.closingIssuesReferences.totalCount;
if (linkedIssues === 0) { if (linkedIssues === 0) {
await addLabel('needs:issue'); await addLabel('needs:issue');
+1 -1
View File
@@ -29,7 +29,7 @@ npm i -g opencode-ai@latest # or bun/pnpm/yarn
scoop bucket add extras; scoop install extras/opencode # Windows scoop bucket add extras; scoop install extras/opencode # Windows
choco install opencode # Windows choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date) brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
brew install opencode # macOS and Linux (official brew formula, updated less frequently) brew install opencode # macOS and Linux (official brew formula, updated less)
paru -S opencode-bin # Arch Linux paru -S opencode-bin # Arch Linux
mise use -g opencode # Any OS mise use -g opencode # Any OS
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
+10 -10
View File
@@ -286,8 +286,8 @@
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2", "@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.71", "@opentui/core": "0.1.72",
"@opentui/solid": "0.1.71", "@opentui/solid": "0.1.72",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
@@ -1201,21 +1201,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.71", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.71", "@opentui/core-darwin-x64": "0.1.71", "@opentui/core-linux-arm64": "0.1.71", "@opentui/core-linux-x64": "0.1.71", "@opentui/core-win32-arm64": "0.1.71", "@opentui/core-win32-x64": "0.1.71", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-0gmqiBnM80QexPEpxv84QZn4h3IKrvLNn/9wIEzpHBtP7hunI1cJqb9fpMIlYDmf03PDW3KFHx9Oo2v+9jtosQ=="], "@opentui/core": ["@opentui/core@0.1.72", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.72", "@opentui/core-darwin-x64": "0.1.72", "@opentui/core-linux-arm64": "0.1.72", "@opentui/core-linux-x64": "0.1.72", "@opentui/core-win32-arm64": "0.1.72", "@opentui/core-win32-x64": "0.1.72", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-l4WQzubBJ80Q0n77Lxuodjwwm8qj/sOa7IXxEAzzDDXY/7bsIhdSpVhRTt+KevBRlok5J+w/KMKYr8UzkA4/hA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.71", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0H8tYWuBDmBuUyuoSFeLi1KTVAa7d4RpftS5E4XUfyRtS5afr08DxXft15Y4unMnKzRNQGYaU9Rk/JdbAy9MKg=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.72", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RoU48kOrhLZYDBiXaDu1LXS2bwRdlJlFle8eUQiqJjLRbMIY34J/srBuL0JnAS3qKW4J34NepUQa0l0/S43Q3w=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.71", "", { "os": "darwin", "cpu": "x64" }, "sha512-3fRfmfpH1GgjphMewETsykRj3FvTJaEf2WttzRPHNaX5zeJQEJYjFLCKFYRujAofvB4I1oxdjGhxEvwD+7AZ0A=="], "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.72", "", { "os": "darwin", "cpu": "x64" }, "sha512-hHUQw8i2LWPToRW1rjAiRqmNf34iJPS9ve9CJDygvFs5JOqUxN5yrfLfKfE+1bQjfFDHnpqW1HUk96iLhkPj8Q=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.71", "", { "os": "linux", "cpu": "arm64" }, "sha512-dmLl53gGLEKCef7Cl4v97JmGc5QV9zDjL6djBLS4X88oVY5F0YU8eLW8/3d0DpD/gWQOOOvGvL6fUP1wON1WIg=="], "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.72", "", { "os": "linux", "cpu": "arm64" }, "sha512-63yml0OQ8tVa0JuDF9lBAWiChX6Q+iDO7lKv7c2n0352n/WyPr3iAgq4uSoH49HXuKeAXY/VwHGjvPzjXD/SDA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.71", "", { "os": "linux", "cpu": "x64" }, "sha512-ZhTfXqNqIb62O/GAjc6MCFheIQLwUULdes4oa0eCab14tyfooc7atzNQPNmsug0blBq0EfqlejRfaOp7f2D9Wg=="], "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.72", "", { "os": "linux", "cpu": "x64" }, "sha512-51veiQXNLvzDsFzsEvt71uK7WhiRe2DnvlJSGBSe6aRRHHxjCFYHzYi7t6bitJqtDTUj+EaMPbH81oZ6xy7tyg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.71", "", { "os": "win32", "cpu": "arm64" }, "sha512-F2zXQFBs/DncYCNIwFx6ps7/9N3tLJ3OZNZZrxUXmwKrO+kziB1lBehBlW5TzzBIPkNY9UBaQg+iekncwiDEcg=="], "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.72", "", { "os": "win32", "cpu": "arm64" }, "sha512-1Ep6OcaYTy1RlLOln+LNN7DL1iNyLwLjG2M8aO0pVJKFvxeD5P7rdRzY065E4uhkHeJIHuduUqxvUjD0dyuwbw=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.71", "", { "os": "win32", "cpu": "x64" }, "sha512-IJBIrwgQFgPLDNEQVLSVaYZMbGETQlfZ1v/gz/zGtU2NwAHCllmPT1E1AauV/7Ex9Da082/dTrnl351IsipcPQ=="], "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.72", "", { "os": "win32", "cpu": "x64" }, "sha512-5QUv91UkOINlkEaPky3kaxmJvshcJMBAX7LZtIroduaKBGpWRA1aogNhPZzp+30WkvgOU7aOtUktAZuFXb9WdQ=="],
"@opentui/solid": ["@opentui/solid@0.1.71", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.71", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-09mimwa2eTkttE72BMv1SNvH/NSf088Ebz0nmrJ7qpmHjbngn7FwlTXK+98sukUTeR6fQqFOnMnuLQieF6QdiA=="], "@opentui/solid": ["@opentui/solid@0.1.72", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.72", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-hytoLPboL/MTY/BQUnf/HlBuNXTVONney0X+PIQI82wT7kMx7+HHI2wnowpM3dyvA7l6NfORSud2cs9kIUBFBw=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"nodeModules": "sha256-vuv1AO4iW6cR/+IgPSLzAb1iif1fo9ti5VzSrX4fo5E=" "nodeModules": "sha256-+QM5BDFxzrm1HY5ealjCm7jIO1t/rpW1q4GGLViPMmA="
} }
+1 -30
View File
@@ -14,36 +14,7 @@
<meta property="og:image" content="/social-share.png" /> <meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" /> <meta property="twitter:image" content="/social-share.png" />
<!-- Theme preload script - applies cached theme to avoid FOUC --> <!-- Theme preload script - applies cached theme to avoid FOUC -->
<script id="oc-theme-preload-script"> <script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
;(function () {
var themeId = localStorage.getItem("opencode-theme-id")
if (!themeId) return
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
var mode = isDark ? "dark" : "light"
document.documentElement.dataset.theme = themeId
document.documentElement.dataset.colorScheme = mode
if (themeId === "oc-1") return
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
if (css) {
var style = document.createElement("style")
style.id = "oc-theme-preload"
style.textContent =
":root{color-scheme:" +
mode +
";--text-mix-blend-mode:" +
(isDark ? "plus-lighter" : "multiply") +
";" +
css +
"}"
document.head.appendChild(style)
}
})()
</script>
</head> </head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden"> <body class="antialiased overscroll-none text-12-regular overflow-hidden">
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
+28
View File
@@ -0,0 +1,28 @@
;(function () {
var themeId = localStorage.getItem("opencode-theme-id")
if (!themeId) return
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
var mode = isDark ? "dark" : "light"
document.documentElement.dataset.theme = themeId
document.documentElement.dataset.colorScheme = mode
if (themeId === "oc-1") return
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
if (css) {
var style = document.createElement("style")
style.id = "oc-theme-preload"
style.textContent =
":root{color-scheme:" +
mode +
";--text-mix-blend-mode:" +
(isDark ? "plus-lighter" : "multiply") +
";" +
css +
"}"
document.head.appendChild(style)
}
})()
-3
View File
@@ -38,9 +38,6 @@ declare global {
} }
const defaultServerUrl = iife(() => { const defaultServerUrl = iife(() => {
const param = new URLSearchParams(document.location.search).get("url")
if (param) return param
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096" if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (window.__OPENCODE__) return `http://127.0.0.1:${window.__OPENCODE__.port}` if (window.__OPENCODE__) return `http://127.0.0.1:${window.__OPENCODE__.port}`
if (import.meta.env.DEV) if (import.meta.env.DEV)
@@ -129,9 +129,9 @@ export default function Download() {
</code> </code>
<CopyStatus /> <CopyStatus />
</button> </button>
<button data-component="cli-row" onClick={handleCopyClick("brew install opencode")}> <button data-component="cli-row" onClick={handleCopyClick("brew install anomalyco/tap/opencode")}>
<code> <code>
brew install <strong>opencode</strong> brew install <strong>anomalyco/tap/opencode</strong>
</code> </code>
<CopyStatus /> <CopyStatus />
</button> </button>
+1 -1
View File
@@ -140,7 +140,7 @@ export default function Home() {
<button data-copy data-slot="command" onClick={handleCopyClick}> <button data-copy data-slot="command" onClick={handleCopyClick}>
<span> <span>
<span data-slot="protocol">brew install </span> <span data-slot="protocol">brew install </span>
<span data-slot="highlight">opencode</span> <span data-slot="highlight">anomalyco/tap/opencode</span>
</span> </span>
<CopyStatus /> <CopyStatus />
</button> </button>
+199 -68
View File
@@ -1,11 +1,13 @@
import { Billing } from "@opencode-ai/console-core/billing.js" import { Billing } from "@opencode-ai/console-core/billing.js"
import type { APIEvent } from "@solidjs/start/server" import type { APIEvent } from "@solidjs/start/server"
import { and, Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js" import { and, Database, eq, isNull, sql } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable, PaymentTable, SubscriptionTable } from "@opencode-ai/console-core/schema/billing.sql.js" import { BillingTable, PaymentTable, SubscriptionTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { Identifier } from "@opencode-ai/console-core/identifier.js" import { Identifier } from "@opencode-ai/console-core/identifier.js"
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js" import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
import { Actor } from "@opencode-ai/console-core/actor.js" import { Actor } from "@opencode-ai/console-core/actor.js"
import { Resource } from "@opencode-ai/console-resource" import { Resource } from "@opencode-ai/console-resource"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { AuthTable } from "@opencode-ai/console-core/schema/auth.sql.js"
export async function POST(input: APIEvent) { export async function POST(input: APIEvent) {
const body = await Billing.stripe().webhooks.constructEventAsync( const body = await Billing.stripe().webhooks.constructEventAsync(
@@ -39,7 +41,7 @@ export async function POST(input: APIEvent) {
.where(eq(BillingTable.customerID, customerID)) .where(eq(BillingTable.customerID, customerID))
}) })
} }
if (body.type === "checkout.session.completed") { if (body.type === "checkout.session.completed" && body.data.object.mode === "payment") {
const workspaceID = body.data.object.metadata?.workspaceID const workspaceID = body.data.object.metadata?.workspaceID
const amountInCents = body.data.object.metadata?.amount && parseInt(body.data.object.metadata?.amount) const amountInCents = body.data.object.metadata?.amount && parseInt(body.data.object.metadata?.amount)
const customerID = body.data.object.customer as string const customerID = body.data.object.customer as string
@@ -102,85 +104,112 @@ export async function POST(input: APIEvent) {
}) })
}) })
} }
if (body.type === "charge.refunded") { if (body.type === "checkout.session.completed" && body.data.object.mode === "subscription") {
const workspaceID = body.data.object.custom_fields.find((f) => f.key === "workspaceid")?.text?.value
const amountInCents = body.data.object.amount_total as number
const customerID = body.data.object.customer as string const customerID = body.data.object.customer as string
const paymentIntentID = body.data.object.payment_intent as string const customerEmail = body.data.object.customer_details?.email as string
if (!customerID) throw new Error("Customer ID not found") const invoiceID = body.data.object.invoice as string
if (!paymentIntentID) throw new Error("Payment ID not found") const subscriptionID = body.data.object.subscription as string
const promoCode = body.data.object.discounts?.[0]?.promotion_code as string
const workspaceID = await Database.use((tx) =>
tx
.select({
workspaceID: BillingTable.workspaceID,
})
.from(BillingTable)
.where(eq(BillingTable.customerID, customerID))
.then((rows) => rows[0]?.workspaceID),
)
if (!workspaceID) throw new Error("Workspace ID not found") if (!workspaceID) throw new Error("Workspace ID not found")
const amount = await Database.use((tx) =>
tx
.select({
amount: PaymentTable.amount,
})
.from(PaymentTable)
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
.then((rows) => rows[0]?.amount),
)
if (!amount) throw new Error("Payment not found")
await Database.transaction(async (tx) => {
await tx
.update(PaymentTable)
.set({
timeRefunded: new Date(body.created * 1000),
})
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} - ${amount}`,
})
.where(eq(BillingTable.workspaceID, workspaceID))
})
}
if (body.type === "invoice.payment_succeeded" && body.data.object.billing_reason === "subscription_cycle") {
const invoiceID = body.data.object.id as string
const amountInCents = body.data.object.amount_paid
const customerID = body.data.object.customer as string
const subscriptionID = body.data.object.parent?.subscription_details?.subscription as string
if (!customerID) throw new Error("Customer ID not found") if (!customerID) throw new Error("Customer ID not found")
if (!amountInCents) throw new Error("Amount not found")
if (!invoiceID) throw new Error("Invoice ID not found") if (!invoiceID) throw new Error("Invoice ID not found")
if (!subscriptionID) throw new Error("Subscription ID not found") if (!subscriptionID) throw new Error("Subscription ID not found")
// get payment id from invoice
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, { const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
expand: ["payments"], expand: ["payments"],
}) })
const paymentID = invoice.payments?.data[0].payment.payment_intent as string const paymentID = invoice.payments?.data[0].payment.payment_intent as string
if (!paymentID) throw new Error("Payment ID not found") if (!paymentID) throw new Error("Payment ID not found")
const workspaceID = await Database.use((tx) => // get payment method for the payment intent
tx const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, {
.select({ workspaceID: BillingTable.workspaceID }) expand: ["payment_method"],
.from(BillingTable) })
.where(eq(BillingTable.customerID, customerID)) const paymentMethod = paymentIntent.payment_method
.then((rows) => rows[0]?.workspaceID), if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded")
)
if (!workspaceID) throw new Error("Workspace ID not found for customer")
await Database.use((tx) => // get coupon id from promotion code
tx.insert(PaymentTable).values({ const couponID = await (async () => {
workspaceID, if (!promoCode) return
id: Identifier.create("payment"), const coupon = await Billing.stripe().promotionCodes.retrieve(promoCode)
amount: centsToMicroCents(amountInCents), const couponID = coupon.coupon.id
paymentID, if (!couponID) throw new Error("Coupon not found for promotion code")
invoiceID, return couponID
customerID, })()
}),
) // get user
await Actor.provide("system", { workspaceID }, async () => {
// look up current billing
const billing = await Billing.get()
if (!billing) throw new Error(`Workspace with ID ${workspaceID} not found`)
// Temporarily skip this check because during Black drop, user can checkout
// as a new customer
//if (billing.customerID !== customerID) throw new Error("Customer ID mismatch")
// Temporarily check the user to apply to. After Black drop, we will allow
// look up the user to apply to
const users = await Database.use((tx) =>
tx
.select({ id: UserTable.id, email: AuthTable.subject })
.from(UserTable)
.innerJoin(AuthTable, and(eq(AuthTable.accountID, UserTable.accountID), eq(AuthTable.provider, "email")))
.where(and(eq(UserTable.workspaceID, workspaceID), isNull(UserTable.timeDeleted))),
)
const user = users.find((u) => u.email === customerEmail) ?? users[0]
if (!user) {
console.error(`Error: User with email ${customerEmail} not found in workspace ${workspaceID}`)
process.exit(1)
}
// set customer metadata
if (!billing?.customerID) {
await Billing.stripe().customers.update(customerID, {
metadata: {
workspaceID,
},
})
}
await Database.transaction(async (tx) => {
await tx
.update(BillingTable)
.set({
customerID,
subscriptionID,
subscriptionCouponID: couponID,
paymentMethodID: paymentMethod.id,
paymentMethodLast4: paymentMethod.card?.last4 ?? null,
paymentMethodType: paymentMethod.type,
})
.where(eq(BillingTable.workspaceID, workspaceID))
await tx.insert(SubscriptionTable).values({
workspaceID,
id: Identifier.create("subscription"),
userID: user.id,
})
await tx.insert(PaymentTable).values({
workspaceID,
id: Identifier.create("payment"),
amount: centsToMicroCents(amountInCents),
paymentID,
invoiceID,
customerID,
enrichment: {
type: "subscription",
couponID,
},
})
})
})
} }
if (body.type === "customer.subscription.created") { if (body.type === "customer.subscription.created") {
const data = { const data = {
@@ -377,11 +406,113 @@ export async function POST(input: APIEvent) {
if (!workspaceID) throw new Error("Workspace ID not found for subscription") if (!workspaceID) throw new Error("Workspace ID not found for subscription")
await Database.transaction(async (tx) => { await Database.transaction(async (tx) => {
await tx.update(BillingTable).set({ subscriptionID: null }).where(eq(BillingTable.workspaceID, workspaceID)) await tx
.update(BillingTable)
.set({ subscriptionID: null, subscriptionCouponID: null })
.where(eq(BillingTable.workspaceID, workspaceID))
await tx.delete(SubscriptionTable).where(eq(SubscriptionTable.workspaceID, workspaceID)) await tx.delete(SubscriptionTable).where(eq(SubscriptionTable.workspaceID, workspaceID))
}) })
} }
if (body.type === "invoice.payment_succeeded") {
if (body.data.object.billing_reason === "subscription_cycle") {
const invoiceID = body.data.object.id as string
const amountInCents = body.data.object.amount_paid
const customerID = body.data.object.customer as string
const subscriptionID = body.data.object.parent?.subscription_details?.subscription as string
if (!customerID) throw new Error("Customer ID not found")
if (!invoiceID) throw new Error("Invoice ID not found")
if (!subscriptionID) throw new Error("Subscription ID not found")
// get coupon id from subscription
const subscriptionData = await Billing.stripe().subscriptions.retrieve(subscriptionID, {
expand: ["discounts"],
})
const couponID =
typeof subscriptionData.discounts[0] === "string"
? subscriptionData.discounts[0]
: subscriptionData.discounts[0]?.coupon?.id
// get payment id from invoice
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
expand: ["payments"],
})
const paymentID = invoice.payments?.data[0].payment.payment_intent as string
if (!paymentID) {
// payment id can be undefined when using coupon
if (!couponID) throw new Error("Payment ID not found")
}
const workspaceID = await Database.use((tx) =>
tx
.select({ workspaceID: BillingTable.workspaceID })
.from(BillingTable)
.where(eq(BillingTable.customerID, customerID))
.then((rows) => rows[0]?.workspaceID),
)
if (!workspaceID) throw new Error("Workspace ID not found for customer")
await Database.use((tx) =>
tx.insert(PaymentTable).values({
workspaceID,
id: Identifier.create("payment"),
amount: centsToMicroCents(amountInCents),
paymentID,
invoiceID,
customerID,
enrichment: {
type: "subscription",
couponID,
},
}),
)
}
}
if (body.type === "charge.refunded") {
const customerID = body.data.object.customer as string
const paymentIntentID = body.data.object.payment_intent as string
if (!customerID) throw new Error("Customer ID not found")
if (!paymentIntentID) throw new Error("Payment ID not found")
const workspaceID = await Database.use((tx) =>
tx
.select({
workspaceID: BillingTable.workspaceID,
})
.from(BillingTable)
.where(eq(BillingTable.customerID, customerID))
.then((rows) => rows[0]?.workspaceID),
)
if (!workspaceID) throw new Error("Workspace ID not found")
const amount = await Database.use((tx) =>
tx
.select({
amount: PaymentTable.amount,
})
.from(PaymentTable)
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
.then((rows) => rows[0]?.amount),
)
if (!amount) throw new Error("Payment not found")
await Database.transaction(async (tx) => {
await tx
.update(PaymentTable)
.set({
timeRefunded: new Date(body.created * 1000),
})
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} - ${amount}`,
})
.where(eq(BillingTable.workspaceID, workspaceID))
})
}
})() })()
.then((message) => { .then((message) => {
return Response.json({ message: message ?? "done" }, { status: 200 }) return Response.json({ message: message ?? "done" }, { status: 200 })
+1 -30
View File
@@ -13,36 +13,7 @@
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" /> <meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
<meta property="og:image" content="/social-share.png" /> <meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" /> <meta property="twitter:image" content="/social-share.png" />
<script id="oc-theme-preload-script"> <script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
;(function () {
var themeId = localStorage.getItem("opencode-theme-id")
if (!themeId) return
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
var mode = isDark ? "dark" : "light"
document.documentElement.dataset.theme = themeId
document.documentElement.dataset.colorScheme = mode
if (themeId === "oc-1") return
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
if (css) {
var style = document.createElement("style")
style.id = "oc-theme-preload"
style.textContent =
":root{color-scheme:" +
mode +
";--text-mix-blend-mode:" +
(isDark ? "plus-lighter" : "multiply") +
";" +
css +
"}"
document.head.appendChild(style)
}
})()
</script>
</head> </head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden"> <body class="antialiased overscroll-none text-12-regular overflow-hidden">
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
+1
View File
@@ -6,6 +6,7 @@ const host = process.env.TAURI_DEV_HOST
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [appPlugin], plugins: [appPlugin],
publicDir: "../app/public",
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
// //
// 1. prevent Vite from obscuring rust errors // 1. prevent Vite from obscuring rust errors
+2 -2
View File
@@ -81,8 +81,8 @@
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2", "@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.71", "@opentui/core": "0.1.72",
"@opentui/solid": "0.1.71", "@opentui/solid": "0.1.72",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
+2
View File
@@ -3,6 +3,8 @@ import { Global } from "../global"
import fs from "fs/promises" import fs from "fs/promises"
import z from "zod" import z from "zod"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
export namespace Auth { export namespace Auth {
export const Oauth = z export const Oauth = z
.object({ .object({
-2
View File
@@ -341,8 +341,6 @@ export const AuthLoginCommand = cmd({
"Configure via opencode.json options (profile, region, endpoint) or\n" + "Configure via opencode.json options (profile, region, endpoint) or\n" +
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID).", "AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID).",
) )
prompts.outro("Done")
return
} }
if (provider === "opencode") { if (provider === "opencode") {
+2 -16
View File
@@ -97,16 +97,7 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
}) })
} }
import type { EventSource } from "./context/sdk" export function tui(input: { url: string; args: Args; directory?: string; onExit?: () => Promise<void> }) {
export function tui(input: {
url: string
args: Args
directory?: string
fetch?: typeof fetch
events?: EventSource
onExit?: () => Promise<void>
}) {
// promise to prevent immediate exit // promise to prevent immediate exit
return new Promise<void>(async (resolve) => { return new Promise<void>(async (resolve) => {
const mode = await getTerminalBackgroundColor() const mode = await getTerminalBackgroundColor()
@@ -126,12 +117,7 @@ export function tui(input: {
<KVProvider> <KVProvider>
<ToastProvider> <ToastProvider>
<RouteProvider> <RouteProvider>
<SDKProvider <SDKProvider url={input.url} directory={input.directory}>
url={input.url}
directory={input.directory}
fetch={input.fetch}
events={input.events}
>
<SyncProvider> <SyncProvider>
<ThemeProvider mode={mode}> <ThemeProvider mode={mode}>
<LocalProvider> <LocalProvider>
@@ -3,66 +3,21 @@ import { createSimpleContext } from "./helper"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { batch, onCleanup, onMount } from "solid-js" import { batch, onCleanup, onMount } from "solid-js"
export type EventSource = {
on: (handler: (event: Event) => void) => () => void
}
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK", name: "SDK",
init: (props: { url: string; directory?: string; fetch?: typeof fetch; events?: EventSource }) => { init: (props: { url: string; directory?: string }) => {
const abort = new AbortController() const abort = new AbortController()
const sdk = createOpencodeClient({ const sdk = createOpencodeClient({
baseUrl: props.url, baseUrl: props.url,
signal: abort.signal, signal: abort.signal,
directory: props.directory, directory: props.directory,
fetch: props.fetch,
}) })
const emitter = createGlobalEmitter<{ const emitter = createGlobalEmitter<{
[key in Event["type"]]: Extract<Event, { type: key }> [key in Event["type"]]: Extract<Event, { type: key }>
}>() }>()
let queue: Event[] = []
let timer: Timer | undefined
let last = 0
const flush = () => {
if (queue.length === 0) return
const events = queue
queue = []
timer = undefined
last = Date.now()
// Batch all event emissions so all store updates result in a single render
batch(() => {
for (const event of events) {
emitter.emit(event.type, event)
}
})
}
const handleEvent = (event: Event) => {
queue.push(event)
const elapsed = Date.now() - last
if (timer) return
// If we just flushed recently (within 16ms), batch this with future events
// Otherwise, process immediately to avoid latency
if (elapsed < 16) {
timer = setTimeout(flush, 16)
return
}
flush()
}
onMount(async () => { onMount(async () => {
// If an event source is provided, use it instead of SSE
if (props.events) {
const unsub = props.events.on(handleEvent)
onCleanup(unsub)
return
}
// Fall back to SSE
while (true) { while (true) {
if (abort.signal.aborted) break if (abort.signal.aborted) break
const events = await sdk.event.subscribe( const events = await sdk.event.subscribe(
@@ -71,9 +26,36 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
signal: abort.signal, signal: abort.signal,
}, },
) )
let queue: Event[] = []
let timer: Timer | undefined
let last = 0
const flush = () => {
if (queue.length === 0) return
const events = queue
queue = []
timer = undefined
last = Date.now()
// Batch all event emissions so all store updates result in a single render
batch(() => {
for (const event of events) {
emitter.emit(event.type, event)
}
})
}
for await (const event of events.stream) { for await (const event of events.stream) {
handleEvent(event) queue.push(event)
const elapsed = Date.now() - last
if (timer) continue
// If we just flushed recently (within 16ms), batch this with future events
// Otherwise, process immediately to avoid latency
if (elapsed < 16) {
timer = setTimeout(flush, 16)
continue
}
flush()
} }
// Flush any remaining events // Flush any remaining events
@@ -86,7 +68,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
onCleanup(() => { onCleanup(() => {
abort.abort() abort.abort()
if (timer) clearTimeout(timer)
}) })
return { client: sdk, event: emitter, url: props.url } return { client: sdk, event: emitter, url: props.url }
+3 -60
View File
@@ -7,39 +7,11 @@ import { UI } from "@/cli/ui"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
import type { Event } from "@opencode-ai/sdk/v2"
import type { EventSource } from "./context/sdk"
declare global { declare global {
const OPENCODE_WORKER_PATH: string const OPENCODE_WORKER_PATH: string
} }
type RpcClient = ReturnType<typeof Rpc.client<typeof rpc>>
function createWorkerFetch(client: RpcClient): typeof fetch {
const fn = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const request = new Request(input, init)
const body = request.body ? await request.text() : undefined
const result = await client.call("fetch", {
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
body,
})
return new Response(result.body, {
status: result.status,
headers: result.headers,
})
}
return fn as typeof fetch
}
function createEventSource(client: RpcClient): EventSource {
return {
on: (handler) => client.on<Event>("event", handler),
}
}
export const TuiThreadCommand = cmd({ export const TuiThreadCommand = cmd({
command: "$0 [project]", command: "$0 [project]",
describe: "start opencode tui", describe: "start opencode tui",
@@ -108,45 +80,16 @@ export const TuiThreadCommand = cmd({
process.on("SIGUSR2", async () => { process.on("SIGUSR2", async () => {
await client.call("reload", undefined) await client.call("reload", undefined)
}) })
const opts = await resolveNetworkOptions(args)
const server = await client.call("server", opts)
const prompt = await iife(async () => { const prompt = await iife(async () => {
const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined
if (!args.prompt) return piped if (!args.prompt) return piped
return piped ? piped + "\n" + args.prompt : args.prompt return piped ? piped + "\n" + args.prompt : args.prompt
}) })
// Check if server should be started (port or hostname explicitly set in CLI or config)
const networkOpts = await resolveNetworkOptions(args)
const shouldStartServer =
process.argv.includes("--port") ||
process.argv.includes("--hostname") ||
process.argv.includes("--mdns") ||
networkOpts.mdns ||
networkOpts.port !== 0 ||
networkOpts.hostname !== "127.0.0.1"
// Subscribe to events from worker
await client.call("subscribe", { directory: cwd })
let url: string
let customFetch: typeof fetch | undefined
let events: EventSource | undefined
if (shouldStartServer) {
// Start HTTP server for external access
const server = await client.call("server", networkOpts)
url = server.url
} else {
// Use direct RPC communication (no HTTP)
url = "http://opencode.internal"
customFetch = createWorkerFetch(client)
events = createEventSource(client)
}
const tuiPromise = tui({ const tuiPromise = tui({
url, url: server.url,
fetch: customFetch,
events,
args: { args: {
continue: args.continue, continue: args.continue,
sessionID: args.session, sessionID: args.session,
+15 -42
View File
@@ -5,10 +5,8 @@ import { Instance } from "@/project/instance"
import { InstanceBootstrap } from "@/project/bootstrap" import { InstanceBootstrap } from "@/project/bootstrap"
import { Rpc } from "@/util/rpc" import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade" import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import type { BunWebSocketData } from "hono/bun" import type { BunWebSocketData } from "hono/bun"
import { Config } from "@/config/config"
await Log.init({ await Log.init({
print: process.argv.includes("--print-logs"), print: process.argv.includes("--print-logs"),
@@ -31,46 +29,19 @@ process.on("uncaughtException", (e) => {
}) })
}) })
// Subscribe to global events and forward them via RPC let server: Bun.Server<BunWebSocketData>
GlobalBus.on("event", (event) => {
Rpc.emit("global.event", event)
})
let server: Bun.Server<BunWebSocketData> | undefined
export const rpc = { export const rpc = {
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) { async server(input: { port: number; hostname: string; mdns?: boolean }) {
const request = new Request(input.url, {
method: input.method,
headers: input.headers,
body: input.body,
})
const response = await Server.App().fetch(request)
const body = await response.text()
return {
status: response.status,
headers: Object.fromEntries(response.headers.entries()),
body,
}
},
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
if (server) await server.stop(true) if (server) await server.stop(true)
server = Server.listen(input) try {
return { url: server.url.toString() } server = Server.listen(input)
}, return {
async subscribe(input: { directory: string }) { url: server.url.toString(),
return Instance.provide({ }
directory: input.directory, } catch (e) {
init: InstanceBootstrap, console.error(e)
fn: async () => { throw e
Bus.subscribeAll((event) => { }
Rpc.emit("event", event)
})
// Emit connected event
Rpc.emit("event", { type: "server.connected", properties: {} })
return { subscribed: true }
},
})
}, },
async checkUpgrade(input: { directory: string }) { async checkUpgrade(input: { directory: string }) {
await Instance.provide({ await Instance.provide({
@@ -88,7 +59,9 @@ export const rpc = {
async shutdown() { async shutdown() {
Log.Default.info("worker shutting down") Log.Default.info("worker shutting down")
await Instance.disposeAll() await Instance.disposeAll()
if (server) server.stop(true) // TODO: this should be awaited, but ws connections are
// causing this to hang, need to revisit this
server.stop(true)
}, },
} }
+1 -1
View File
@@ -33,7 +33,7 @@ await Promise.all([
fs.mkdir(Global.Path.bin, { recursive: true }), fs.mkdir(Global.Path.bin, { recursive: true }),
]) ])
const CACHE_VERSION = "15" const CACHE_VERSION = "16"
const version = await Bun.file(path.join(Global.Path.cache, "version")) const version = await Bun.file(path.join(Global.Path.cache, "version"))
.text() .text()
+3 -1
View File
@@ -99,14 +99,16 @@ const cli = yargs(hideBin(process.argv))
.command(GithubCommand) .command(GithubCommand)
.command(PrCommand) .command(PrCommand)
.command(SessionCommand) .command(SessionCommand)
.fail((msg) => { .fail((msg, err) => {
if ( if (
msg?.startsWith("Unknown argument") || msg?.startsWith("Unknown argument") ||
msg?.startsWith("Not enough non-option arguments") || msg?.startsWith("Not enough non-option arguments") ||
msg?.startsWith("Invalid values:") msg?.startsWith("Invalid values:")
) { ) {
if (err) throw err
cli.showHelp("log") cli.showHelp("log")
} }
if (err) throw err
process.exit(1) process.exit(1)
}) })
.strict() .strict()
+417
View File
@@ -0,0 +1,417 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { Log } from "../util/log"
import { OAUTH_DUMMY_KEY } from "../auth"
const log = Log.create({ service: "plugin.codex" })
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
const ISSUER = "https://auth.openai.com"
const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
const OAUTH_PORT = 1455
interface PkceCodes {
verifier: string
challenge: string
}
async function generatePKCE(): Promise<PkceCodes> {
const verifier = generateRandomString(43)
const encoder = new TextEncoder()
const data = encoder.encode(verifier)
const hash = await crypto.subtle.digest("SHA-256", data)
const challenge = base64UrlEncode(hash)
return { verifier, challenge }
}
function generateRandomString(length: number): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
const bytes = crypto.getRandomValues(new Uint8Array(length))
return Array.from(bytes)
.map((b) => chars[b % chars.length])
.join("")
}
function base64UrlEncode(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
const binary = String.fromCharCode(...bytes)
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}
function generateState(): string {
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
}
function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes, state: string): string {
const params = new URLSearchParams({
response_type: "code",
client_id: CLIENT_ID,
redirect_uri: redirectUri,
scope: "openid profile email offline_access",
code_challenge: pkce.challenge,
code_challenge_method: "S256",
id_token_add_organizations: "true",
codex_cli_simplified_flow: "true",
state,
originator: "opencode",
})
return `${ISSUER}/oauth/authorize?${params.toString()}`
}
interface TokenResponse {
id_token: string
access_token: string
refresh_token: string
expires_in?: number
}
async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise<TokenResponse> {
const response = await fetch(`${ISSUER}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
client_id: CLIENT_ID,
code_verifier: pkce.verifier,
}).toString(),
})
if (!response.ok) {
throw new Error(`Token exchange failed: ${response.status}`)
}
return response.json()
}
async function refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
const response = await fetch(`${ISSUER}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: CLIENT_ID,
}).toString(),
})
if (!response.ok) {
throw new Error(`Token refresh failed: ${response.status}`)
}
return response.json()
}
const HTML_SUCCESS = `<!DOCTYPE html>
<html>
<head>
<title>OpenCode - Codex Authorization Successful</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
.container { text-align: center; padding: 2rem; }
h1 { color: #4ade80; margin-bottom: 1rem; }
p { color: #aaa; }
</style>
</head>
<body>
<div class="container">
<h1>Authorization Successful</h1>
<p>You can close this window and return to OpenCode.</p>
</div>
<script>setTimeout(() => window.close(), 2000);</script>
</body>
</html>`
const HTML_ERROR = (error: string) => `<!DOCTYPE html>
<html>
<head>
<title>OpenCode - Codex Authorization Failed</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
.container { text-align: center; padding: 2rem; }
h1 { color: #f87171; margin-bottom: 1rem; }
p { color: #aaa; }
.error { color: #fca5a5; font-family: monospace; margin-top: 1rem; padding: 1rem; background: rgba(248,113,113,0.1); border-radius: 0.5rem; }
</style>
</head>
<body>
<div class="container">
<h1>Authorization Failed</h1>
<p>An error occurred during authorization.</p>
<div class="error">${error}</div>
</div>
</body>
</html>`
interface PendingOAuth {
pkce: PkceCodes
state: string
resolve: (tokens: TokenResponse) => void
reject: (error: Error) => void
}
let oauthServer: ReturnType<typeof Bun.serve> | undefined
let pendingOAuth: PendingOAuth | undefined
async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> {
if (oauthServer) {
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
}
oauthServer = Bun.serve({
port: OAUTH_PORT,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === "/auth/callback") {
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
if (error) {
const errorMsg = errorDescription || error
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
return new Response(HTML_ERROR(errorMsg), {
headers: { "Content-Type": "text/html" },
})
}
if (!code) {
const errorMsg = "Missing authorization code"
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
return new Response(HTML_ERROR(errorMsg), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
if (!pendingOAuth || state !== pendingOAuth.state) {
const errorMsg = "Invalid state - potential CSRF attack"
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
return new Response(HTML_ERROR(errorMsg), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
const current = pendingOAuth
pendingOAuth = undefined
exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
.then((tokens) => current.resolve(tokens))
.catch((err) => current.reject(err))
return new Response(HTML_SUCCESS, {
headers: { "Content-Type": "text/html" },
})
}
if (url.pathname === "/cancel") {
pendingOAuth?.reject(new Error("Login cancelled"))
pendingOAuth = undefined
return new Response("Login cancelled", { status: 200 })
}
return new Response("Not found", { status: 404 })
},
})
log.info("codex oauth server started", { port: OAUTH_PORT })
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
}
function stopOAuthServer() {
if (oauthServer) {
oauthServer.stop()
oauthServer = undefined
log.info("codex oauth server stopped")
}
}
function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResponse> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(
() => {
if (pendingOAuth) {
pendingOAuth = undefined
reject(new Error("OAuth callback timeout - authorization took too long"))
}
},
5 * 60 * 1000,
) // 5 minute timeout
pendingOAuth = {
pkce,
state,
resolve: (tokens) => {
clearTimeout(timeout)
resolve(tokens)
},
reject: (error) => {
clearTimeout(timeout)
reject(error)
},
}
})
}
export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
return {
auth: {
provider: "openai",
async loader(getAuth, provider) {
const auth = await getAuth()
if (auth.type !== "oauth") return {}
// Filter models to only allowed Codex models for OAuth
const allowedModels = new Set(["gpt-5.1-codex-max", "gpt-5.1-codex-mini", "gpt-5.2", "gpt-5.2-codex"])
for (const modelId of Object.keys(provider.models)) {
if (!allowedModels.has(modelId)) {
delete provider.models[modelId]
}
}
if (!provider.models["gpt-5.2-codex"]) {
provider.models["gpt-5.2-codex"] = {
id: "gpt-5.2-codex",
providerID: "openai",
api: {
id: "gpt-5.2-codex",
url: "https://chatgpt.com/backend-api/codex",
npm: "@ai-sdk/openai",
},
name: "GPT-5.2 Codex",
capabilities: {
temperature: false,
reasoning: true,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
},
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: 400000, output: 128000 },
status: "active",
options: {},
headers: {},
}
}
// Zero out costs for Codex (included with ChatGPT subscription)
for (const model of Object.values(provider.models)) {
model.cost = {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
}
}
return {
apiKey: OAUTH_DUMMY_KEY,
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
// Remove dummy API key authorization header
if (init?.headers) {
if (init.headers instanceof Headers) {
init.headers.delete("authorization")
init.headers.delete("Authorization")
} else if (Array.isArray(init.headers)) {
init.headers = init.headers.filter(([key]) => key.toLowerCase() !== "authorization")
} else {
delete init.headers["authorization"]
delete init.headers["Authorization"]
}
}
const currentAuth = await getAuth()
if (currentAuth.type !== "oauth") return fetch(requestInput, init)
// Check if token needs refresh
if (!currentAuth.access || currentAuth.expires < Date.now()) {
log.info("refreshing codex access token")
const tokens = await refreshAccessToken(currentAuth.refresh)
await input.client.auth.set({
path: { id: "codex" },
body: {
type: "oauth",
refresh: tokens.refresh_token,
access: tokens.access_token,
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
},
})
currentAuth.access = tokens.access_token
}
// Build headers
const headers = new Headers()
if (init?.headers) {
if (init.headers instanceof Headers) {
init.headers.forEach((value, key) => headers.set(key, value))
} else if (Array.isArray(init.headers)) {
for (const [key, value] of init.headers) {
if (value !== undefined) headers.set(key, String(value))
}
} else {
for (const [key, value] of Object.entries(init.headers)) {
if (value !== undefined) headers.set(key, String(value))
}
}
}
// Set authorization header with access token
headers.set("authorization", `Bearer ${currentAuth.access}`)
// Rewrite URL to Codex endpoint
let url: URL
if (typeof requestInput === "string") {
url = new URL(requestInput)
} else if (requestInput instanceof URL) {
url = requestInput
} else {
url = new URL(requestInput.url)
}
// If this is a messages/responses request, redirect to Codex endpoint
if (url.pathname.includes("/v1/responses") || url.pathname.includes("/chat/completions")) {
url = new URL(CODEX_API_ENDPOINT)
}
return fetch(url, {
...init,
headers,
})
},
}
},
methods: [
{
label: "ChatGPT Pro/Plus",
type: "oauth",
authorize: async () => {
const { redirectUri } = await startOAuthServer()
const pkce = await generatePKCE()
const state = generateState()
const authUrl = buildAuthorizeUrl(redirectUri, pkce, state)
const callbackPromise = waitForOAuthCallback(pkce, state)
return {
url: authUrl,
instructions: "Complete authorization in your browser. This window will close automatically.",
method: "auto" as const,
callback: async () => {
const tokens = await callbackPromise
stopOAuthServer()
return {
type: "success" as const,
refresh: tokens.refresh_token,
access: tokens.access_token,
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
}
},
}
},
},
],
},
}
}
+18 -2
View File
@@ -7,11 +7,15 @@ import { Server } from "../server/server"
import { BunProc } from "../bun" import { BunProc } from "../bun"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
import { CodexAuthPlugin } from "./codex"
export namespace Plugin { export namespace Plugin {
const log = Log.create({ service: "plugin" }) const log = Log.create({ service: "plugin" })
const BUILTIN = ["opencode-copilot-auth@0.0.11", "opencode-anthropic-auth@0.0.5"] const BUILTIN = ["opencode-copilot-auth@0.0.11", "opencode-anthropic-auth@0.0.8"]
// Built-in plugins that are directly imported (not installed from npm)
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin]
const state = Instance.state(async () => { const state = Instance.state(async () => {
const client = createOpencodeClient({ const client = createOpencodeClient({
@@ -20,7 +24,7 @@ export namespace Plugin {
fetch: async (...args) => Server.App().fetch(...args), fetch: async (...args) => Server.App().fetch(...args),
}) })
const config = await Config.get() const config = await Config.get()
const hooks = [] const hooks: Hooks[] = []
const input: PluginInput = { const input: PluginInput = {
client, client,
project: Instance.project, project: Instance.project,
@@ -29,11 +33,23 @@ export namespace Plugin {
serverUrl: Server.url(), serverUrl: Server.url(),
$: Bun.$, $: Bun.$,
} }
// Load internal plugins first
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
for (const plugin of INTERNAL_PLUGINS) {
log.info("loading internal plugin", { name: plugin.name })
const init = await plugin(input)
hooks.push(init)
}
}
const plugins = [...(config.plugin ?? [])] const plugins = [...(config.plugin ?? [])]
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) { if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
plugins.push(...BUILTIN) plugins.push(...BUILTIN)
} }
for (let plugin of plugins) { for (let plugin of plugins) {
// ignore old codex plugin since it is supported first party now
if (plugin.includes("opencode-openai-codex-auth")) continue
log.info("loading plugin", { path: plugin }) log.info("loading plugin", { path: plugin })
if (!plugin.startsWith("file://")) { if (!plugin.startsWith("file://")) {
const lastAtIndex = plugin.lastIndexOf("@") const lastAtIndex = plugin.lastIndexOf("@")
+1 -4
View File
@@ -74,6 +74,7 @@ export namespace Server {
const app = new Hono() const app = new Hono()
export const App: () => Hono = lazy( export const App: () => Hono = lazy(
() => () =>
// TODO: Break server.ts into smaller route files to fix type inference
app app
.onError((err, c) => { .onError((err, c) => {
log.error("failed", { log.error("failed", {
@@ -2826,10 +2827,6 @@ export namespace Server {
host: "app.opencode.ai", host: "app.opencode.ai",
}, },
}) })
response.headers.set(
"Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'",
)
return response return response
}) as unknown as Hono, }) as unknown as Hono,
) )
+8 -1
View File
@@ -151,12 +151,19 @@ export namespace Session {
directory: Instance.directory, directory: Instance.directory,
}) })
const msgs = await messages({ sessionID: input.sessionID }) const msgs = await messages({ sessionID: input.sessionID })
const idMap = new Map<string, string>()
for (const msg of msgs) { for (const msg of msgs) {
if (input.messageID && msg.info.id >= input.messageID) break if (input.messageID && msg.info.id >= input.messageID) break
const newID = Identifier.ascending("message")
idMap.set(msg.info.id, newID)
const parentID = msg.info.role === "assistant" && msg.info.parentID ? idMap.get(msg.info.parentID) : undefined
const cloned = await updateMessage({ const cloned = await updateMessage({
...msg.info, ...msg.info,
sessionID: session.id, sessionID: session.id,
id: Identifier.ascending("message"), id: newID,
...(parentID && { parentID }),
}) })
for (const part of msg.parts) { for (const part of msg.parts) {
+44 -17
View File
@@ -1,3 +1,5 @@
import os from "os"
import { Installation } from "@/installation"
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { import {
@@ -19,6 +21,7 @@ import { Plugin } from "@/plugin"
import { SystemPrompt } from "./system" import { SystemPrompt } from "./system"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { PermissionNext } from "@/permission/next" import { PermissionNext } from "@/permission/next"
import { Auth } from "@/auth"
export namespace LLM { export namespace LLM {
const log = Log.create({ service: "llm" }) const log = Log.create({ service: "llm" })
@@ -82,12 +85,24 @@ export namespace LLM {
} }
const provider = await Provider.getProvider(input.model.providerID) const provider = await Provider.getProvider(input.model.providerID)
const auth = await Auth.get(input.model.providerID)
const isCodex = provider.id === "openai" && auth?.type === "oauth"
const variant = const variant =
!input.small && input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {} !input.small && input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {}
const base = input.small const base = input.small
? ProviderTransform.smallOptions(input.model) ? ProviderTransform.smallOptions(input.model)
: ProviderTransform.options(input.model, input.sessionID, provider.options) : ProviderTransform.options(input.model, input.sessionID, provider.options)
const options = pipe(base, mergeDeep(input.model.options), mergeDeep(input.agent.options), mergeDeep(variant)) const options: Record<string, any> = pipe(
base,
mergeDeep(input.model.options),
mergeDeep(input.agent.options),
mergeDeep(variant),
)
if (isCodex) {
options.instructions = SystemPrompt.instructions()
options.store = false
}
const params = await Plugin.trigger( const params = await Plugin.trigger(
"chat.params", "chat.params",
@@ -108,16 +123,14 @@ export namespace LLM {
}, },
) )
l.info("params", { const maxOutputTokens = isCodex
params, ? undefined
}) : ProviderTransform.maxOutputTokens(
input.model.api.npm,
const maxOutputTokens = ProviderTransform.maxOutputTokens( params.options,
input.model.api.npm, input.model.limit.output,
params.options, OUTPUT_TOKEN_MAX,
input.model.limit.output, )
OUTPUT_TOKEN_MAX,
)
const tools = await resolveTools(input) const tools = await resolveTools(input)
@@ -157,6 +170,13 @@ export namespace LLM {
maxOutputTokens, maxOutputTokens,
abortSignal: input.abort, abortSignal: input.abort,
headers: { headers: {
...(isCodex
? {
originator: "opencode",
"User-Agent": `opencode/${Installation.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
session_id: input.sessionID,
}
: undefined),
...(input.model.providerID.startsWith("opencode") ...(input.model.providerID.startsWith("opencode")
? { ? {
"x-opencode-project": Instance.project.id, "x-opencode-project": Instance.project.id,
@@ -169,12 +189,19 @@ export namespace LLM {
}, },
maxRetries: input.retries ?? 0, maxRetries: input.retries ?? 0,
messages: [ messages: [
...system.map( ...(isCodex
(x): ModelMessage => ({ ? [
role: "system", {
content: x, role: "user",
}), content: system.join("\n\n"),
), } as ModelMessage,
]
: system.map(
(x): ModelMessage => ({
role: "system",
content: x,
}),
)),
...input.messages, ...input.messages,
], ],
model: wrapLanguageModel({ model: wrapLanguageModel({
@@ -0,0 +1 @@
You are a coding agent running in the opencode, a terminal-based coding assistant. opencode is an open source project. You are expected to be precise, safe, and helpful.
+5
View File
@@ -14,6 +14,7 @@ import PROMPT_GEMINI from "./prompt/gemini.txt"
import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt" import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
import PROMPT_CODEX from "./prompt/codex.txt" import PROMPT_CODEX from "./prompt/codex.txt"
import PROMPT_CODEX_INSTRUCTIONS from "./prompt/codex_header.txt"
import type { Provider } from "@/provider/provider" import type { Provider } from "@/provider/provider"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
@@ -23,6 +24,10 @@ export namespace SystemPrompt {
return [] return []
} }
export function instructions() {
return PROMPT_CODEX_INSTRUCTIONS.trim()
}
export function provider(model: Provider.Model) { export function provider(model: Provider.Model) {
if (model.api.id.includes("gpt-5")) return [PROMPT_CODEX] if (model.api.id.includes("gpt-5")) return [PROMPT_CODEX]
if (model.api.id.includes("gpt-") || model.api.id.includes("o1") || model.api.id.includes("o3")) if (model.api.id.includes("gpt-") || model.api.id.includes("o1") || model.api.id.includes("o3"))
-24
View File
@@ -13,16 +13,11 @@ export namespace Rpc {
} }
} }
export function emit(event: string, data: unknown) {
postMessage(JSON.stringify({ type: "rpc.event", event, data }))
}
export function client<T extends Definition>(target: { export function client<T extends Definition>(target: {
postMessage: (data: string) => void | null postMessage: (data: string) => void | null
onmessage: ((this: Worker, ev: MessageEvent<any>) => any) | null onmessage: ((this: Worker, ev: MessageEvent<any>) => any) | null
}) { }) {
const pending = new Map<number, (result: any) => void>() const pending = new Map<number, (result: any) => void>()
const listeners = new Map<string, Set<(data: any) => void>>()
let id = 0 let id = 0
target.onmessage = async (evt) => { target.onmessage = async (evt) => {
const parsed = JSON.parse(evt.data) const parsed = JSON.parse(evt.data)
@@ -33,14 +28,6 @@ export namespace Rpc {
pending.delete(parsed.id) pending.delete(parsed.id)
} }
} }
if (parsed.type === "rpc.event") {
const handlers = listeners.get(parsed.event)
if (handlers) {
for (const handler of handlers) {
handler(parsed.data)
}
}
}
} }
return { return {
call<Method extends keyof T>(method: Method, input: Parameters<T[Method]>[0]): Promise<ReturnType<T[Method]>> { call<Method extends keyof T>(method: Method, input: Parameters<T[Method]>[0]): Promise<ReturnType<T[Method]>> {
@@ -50,17 +37,6 @@ export namespace Rpc {
target.postMessage(JSON.stringify({ type: "rpc.request", method, input, id: requestId })) target.postMessage(JSON.stringify({ type: "rpc.request", method, input, id: requestId }))
}) })
}, },
on<Data>(event: string, handler: (data: Data) => void) {
let handlers = listeners.get(event)
if (!handlers) {
handlers = new Set()
listeners.set(event, handlers)
}
handlers.add(handler)
return () => {
handlers!.delete(handler)
}
},
} }
} }
} }
+4 -1
View File
@@ -77,7 +77,7 @@
[data-slot="user-message-text"] { [data-slot="user-message-text"] {
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; word-break: break-word;
overflow: hidden; overflow: hidden;
background: var(--surface-base); background: var(--surface-base);
padding: 8px 12px; padding: 8px 12px;
@@ -254,6 +254,7 @@
scrollbar-width: none; scrollbar-width: none;
-ms-overflow-style: none; -ms-overflow-style: none;
&::-webkit-scrollbar { &::-webkit-scrollbar {
display: none; display: none;
} }
@@ -271,6 +272,7 @@
/* Hide scrollbar */ /* Hide scrollbar */
scrollbar-width: none; scrollbar-width: none;
-ms-overflow-style: none; -ms-overflow-style: none;
&::-webkit-scrollbar { &::-webkit-scrollbar {
display: none; display: none;
} }
@@ -458,6 +460,7 @@
from { from {
--border-angle: 0deg; --border-angle: 0deg;
} }
to { to {
--border-angle: 360deg; --border-angle: 360deg;
} }