Merge branch 'dev' into opentui

This commit is contained in:
Dax Raad 2025-10-07 04:10:07 -04:00
commit 1b10dbd785
46 changed files with 1685 additions and 4503 deletions

View file

@ -100,3 +100,4 @@
| 2025-10-03 | 446,829 (+5,977) | 359,937 (+11,838) | 806,766 (+17,815) |
| 2025-10-04 | 452,561 (+5,732) | 370,386 (+10,449) | 822,947 (+16,181) |
| 2025-10-05 | 455,559 (+2,998) | 374,745 (+4,359) | 830,304 (+7,357) |
| 2025-10-06 | 460,927 (+5,368) | 379,489 (+4,744) | 840,416 (+10,112) |

4201
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,7 @@
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
"build": "vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
"start": "vinxi start",
"version": "0.14.3"
"version": "0.14.5"
},
"dependencies": {
"@ibm/plex": "6.4.1",
@ -16,6 +16,7 @@
"@openauthjs/openauth": "catalog:",
"@kobalte/core": "catalog:",
"@jsx-email/render": "1.1.1",
"@opencode-ai/console-resource": "workspace:*",
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.15.0",
"@solidjs/start": "^1.1.0",

View file

@ -73,6 +73,7 @@ export const getActor = async (workspace?: string): Promise<Actor.Info> => {
properties: {
userID: user.id,
workspaceID: user.workspaceID,
accountID: user.accountID,
},
}
}

View file

@ -0,0 +1,7 @@
import { query } from "@solidjs/router"
import { Resource } from "@opencode-ai/console-resource"
export const beta = query(async (workspaceID?: string) => {
"use server"
return Resource.App.stage === "production" ? workspaceID === "wrk_01K46JDFR0E75SG2Q8K172KF3Y" : true
}, "beta")

View file

@ -1,11 +1,29 @@
import { Account } from "@opencode-ai/console-core/account.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { redirect } from "@solidjs/router"
import type { APIEvent } from "@solidjs/start/server"
import { withActor } from "~/context/auth.withActor"
export async function GET(input: APIEvent) {
try {
const workspaces = await withActor(async () => Account.workspaces())
const workspaces = await withActor(async () => {
const actor = Actor.assert("account")
return Database.transaction(async (tx) =>
tx
.select({ id: WorkspaceTable.id })
.from(UserTable)
.innerJoin(WorkspaceTable, eq(UserTable.workspaceID, WorkspaceTable.id))
.where(
and(
eq(UserTable.accountID, actor.properties.accountID),
isNull(UserTable.timeDeleted),
isNull(WorkspaceTable.timeDeleted),
),
),
)
})
return redirect(`/workspace/${workspaces[0].id}`)
} catch {
return redirect("/auth/authorize")

View file

@ -0,0 +1,184 @@
[data-component="workspace-picker"] {
position: relative;
/* Override blue accent colors with neutral colors */
--color-accent: var(--color-border);
--color-accent-hover: var(--color-border);
--color-accent-active: var(--color-border);
--color-primary: var(--color-border);
--color-primary-hover: var(--color-border);
--color-primary-active: var(--color-border);
--color-primary-alpha-20: transparent;
[data-slot="trigger"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
font-family: var(--font-sans);
cursor: pointer;
min-width: 200px;
span {
flex: 1;
text-align: left;
font-weight: 500;
}
}
[data-slot="chevron"] {
flex-shrink: 0;
color: var(--color-text-secondary);
}
[data-slot="dropdown"] button {
text-decoration: none !important;
}
/* Ensure text inside buttons has no underline */
[data-slot="dropdown"] button * {
text-decoration: none !important;
}
[data-slot="dropdown"] {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
margin-top: var(--space-1);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
max-height: 240px;
overflow-y: auto;
@media (prefers-color-scheme: dark) {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
}
[data-slot="option"],
[data-slot="create-option"] {
width: 100%;
padding: var(--space-2-5) var(--space-3);
border: none;
background: none;
color: var(--color-text);
font-size: var(--font-size-sm);
font-family: var(--font-sans);
text-align: left;
cursor: pointer;
text-decoration: none;
&:hover {
background-color: var(--color-surface);
text-decoration: none;
}
&:focus {
text-decoration: none;
}
&:active {
text-decoration: none;
}
&:first-child {
border-top-left-radius: var(--border-radius-sm);
border-top-right-radius: var(--border-radius-sm);
}
&:last-child {
border-bottom-left-radius: var(--border-radius-sm);
border-bottom-right-radius: var(--border-radius-sm);
}
}
[data-slot="option"][data-selected="true"] {
background-color: transparent;
color: var(--color-text);
}
[data-slot="create-option"] {
color: var(--color-text-secondary);
font-weight: 500;
}
[data-slot="create-form"] {
margin-top: var(--space-4);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-surface);
}
[data-slot="create-input-group"] {
display: flex;
gap: var(--space-2);
align-items: center;
@media (max-width: 30rem) {
flex-direction: column;
align-items: stretch;
}
}
[data-slot="create-input"] {
flex: 1;
padding: var(--space-2-5) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
font-family: var(--font-sans);
&:focus {
outline: none;
border-color: var(--color-border);
box-shadow: none;
}
&::placeholder {
color: var(--color-text-muted);
}
}
button[type="submit"],
button[type="button"] {
padding: var(--space-2-5) var(--space-4);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
font-family: var(--font-sans);
font-weight: 500;
cursor: pointer;
white-space: nowrap;
&:focus {
outline: none;
box-shadow: none;
}
&:active {
transform: translateY(1px);
}
&[data-color="primary"] {
background-color: var(--color-text-secondary);
border-color: var(--color-text-secondary);
color: var(--color-bg);
}
@media (max-width: 30rem) {
flex: 1;
}
}
}

View file

@ -0,0 +1,144 @@
import { query, useParams, action, createAsync, redirect } from "@solidjs/router"
import { For, Show, createEffect, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
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 { Workspace } from "@opencode-ai/console-core/workspace.js"
import "./workspace-picker.css"
const getWorkspaces = query(async () => {
"use server"
return withActor(async () => {
return Database.transaction((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))),
)
})
}, "workspaces")
const createWorkspace = action(async (form: FormData) => {
"use server"
const name = form.get("workspaceName") as string
if (name?.trim()) {
return withActor(async () => {
const workspaceID = await Workspace.create({ name: name.trim() })
return redirect(`/workspace/${workspaceID}`)
})
}
}, "createWorkspace")
export function WorkspacePicker() {
const params = useParams()
const workspaces = createAsync(() => getWorkspaces())
const [store, setStore] = createStore({
showForm: false,
showDropdown: false,
})
let dropdownRef: HTMLDivElement | undefined
const currentWorkspace = () => {
const ws = workspaces()?.find((w) => w.id === params.id)
return ws ? ws.name : "Select workspace"
}
const handleWorkspaceNew = () => {
setStore({ showForm: true, showDropdown: false })
}
const handleSelectWorkspace = (workspaceID: string) => {
if (workspaceID === params.id) {
setStore("showDropdown", false)
return
}
window.location.href = `/workspace/${workspaceID}`
}
// Reset signals when workspace ID changes
createEffect(() => {
params.id
setStore("showForm", false)
setStore("showDropdown", false)
})
createEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef && !dropdownRef.contains(event.target as Node)) {
setStore("showDropdown", false)
}
}
document.addEventListener("click", handleClickOutside)
onCleanup(() => document.removeEventListener("click", handleClickOutside))
})
return (
<div data-component="workspace-picker">
<div ref={dropdownRef}>
<div data-slot="trigger" onClick={() => setStore("showDropdown", !store.showDropdown)}>
<span>{currentWorkspace()}</span>
<svg data-slot="chevron" width="12" height="8" viewBox="0 0 12 8" fill="none">
<path
d="M1 1L6 6L11 1"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
<Show when={store.showDropdown}>
<div data-slot="dropdown">
<For each={workspaces()}>
{(workspace) => (
<button
data-slot="option"
data-selected={workspace.id === params.id}
type="button"
onClick={() => handleSelectWorkspace(workspace.id)}
>
{workspace.name || workspace.slug}
</button>
)}
</For>
<button data-slot="create-option" type="button" onClick={() => handleWorkspaceNew()}>
+ Create New Workspace
</button>
</div>
</Show>
</div>
<Show when={store.showForm}>
<form data-slot="create-form" action={createWorkspace} method="post">
<div data-slot="create-input-group">
<input
data-slot="create-input"
type="text"
name="workspaceName"
placeholder="Enter workspace name"
required
autofocus
/>
<button type="submit" data-color="primary">
Create
</button>
<button type="button" onClick={() => setStore("showForm", false)}>
Cancel
</button>
</div>
</form>
</Show>
</div>
)
}

View file

@ -1,11 +1,14 @@
import { Show } from "solid-js"
import { getRequestEvent } from "solid-js/web"
import { query, action, redirect, createAsync, RouteSectionProps, useParams, A } from "@solidjs/router"
import "./workspace.css"
import { useAuthSession } from "~/context/auth.session"
import { IconLogo } from "../component/icon"
import { WorkspacePicker } from "./workspace-picker"
import { withActor } from "~/context/auth.withActor"
import { query, action, redirect, createAsync, RouteSectionProps, useParams, A } from "@solidjs/router"
import { User } from "@opencode-ai/console-core/user.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { getRequestEvent } from "solid-js/web"
import { beta } from "~/lib/beta"
const getUserInfo = query(async (workspaceID: string) => {
"use server"
@ -35,6 +38,7 @@ const logout = action(async () => {
export default function WorkspaceLayout(props: RouteSectionProps) {
const params = useParams()
const userInfo = createAsync(() => getUserInfo(params.id))
const isBeta = createAsync(() => beta(params.id))
return (
<main data-page="workspace">
<header data-component="workspace-header">
@ -44,6 +48,9 @@ export default function WorkspaceLayout(props: RouteSectionProps) {
</A>
</div>
<div data-slot="header-actions">
<Show when={isBeta()}>
<WorkspacePicker />
</Show>
<span data-slot="user">{userInfo()?.email}</span>
<form action={logout} method="post">
<button type="submit" formaction={logout}>

View file

@ -6,28 +6,30 @@ import { PaymentSection } from "./payment-section"
import { UsageSection } from "./usage-section"
import { KeySection } from "./key-section"
import { MemberSection } from "./member-section"
import { SettingsSection } from "./settings-section"
import { Show } from "solid-js"
import { createAsync, query, useParams } from "@solidjs/router"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { withActor } from "~/context/auth.withActor"
import { User } from "@opencode-ai/console-core/user.js"
import { Resource } from "@opencode-ai/console-resource"
import { beta } from "~/lib/beta"
const getUser = query(async (workspaceID: string) => {
const getUserInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
const actor = Actor.assert("user")
const user = await User.fromID(actor.properties.userID)
return {
isAdmin: user?.role === "admin",
isBeta: Resource.App.stage === "production" ? workspaceID === "wrk_01K46JDFR0E75SG2Q8K172KF3Y" : true,
}
}, workspaceID)
}, "user.get")
export default function () {
const params = useParams()
const data = createAsync(() => getUser(params.id))
const userInfo = createAsync(() => getUserInfo(params.id))
const isBeta = createAsync(() => beta(params.id))
return (
<div data-page="workspace-[id]">
<section data-component="title-section">
@ -44,15 +46,16 @@ export default function () {
<div data-slot="sections">
<NewUserSection />
<KeySection />
<Show when={data()?.isAdmin}>
<Show when={data()?.isBeta}>
<Show when={userInfo()?.isAdmin}>
<Show when={isBeta()}>
<SettingsSection />
<MemberSection />
</Show>
<BillingSection />
<MonthlyLimitSection />
</Show>
<UsageSection />
<Show when={data()?.isAdmin}>
<Show when={userInfo()?.isAdmin}>
<PaymentSection />
</Show>
</div>

View file

@ -0,0 +1,95 @@
.root {
[data-slot="section-content"] {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
[data-slot="setting"] {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
@media (max-width: 30rem) {
flex-direction: column;
gap: var(--space-3);
}
}
[data-slot="setting-info"] {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-1);
h3 {
font-size: var(--font-size-md);
font-weight: 500;
line-height: 1.2;
margin: 0;
color: var(--color-text);
}
[data-slot="current-value"] {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
line-height: 1.4;
margin: 0;
}
}
[data-slot="create-form"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
min-width: 15rem;
width: fit-content;
@media (max-width: 30rem) {
width: 100%;
min-width: auto;
}
[data-slot="input-container"] {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
input {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
font-family: var(--font-mono);
&:focus {
outline: none;
border-color: var(--color-accent);
}
&::placeholder {
color: var(--color-text-disabled);
}
}
[data-slot="form-actions"] {
display: flex;
gap: var(--space-2);
justify-content: flex-end;
}
[data-slot="form-error"] {
color: var(--color-danger);
font-size: var(--font-size-sm);
line-height: 1.4;
}
}
}

View file

@ -0,0 +1,124 @@
import { json, action, useParams, useSubmission, createAsync, query } from "@solidjs/router"
import { createEffect, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { withActor } from "~/context/auth.withActor"
import { Workspace } from "@opencode-ai/console-core/workspace.js"
import styles from "./settings-section.module.css"
import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
const getWorkspaceInfo = query(async (workspaceID: string) => {
"use server"
return withActor(
() =>
Database.use((tx) =>
tx
.select({
id: WorkspaceTable.id,
name: WorkspaceTable.name,
slug: WorkspaceTable.slug,
})
.from(WorkspaceTable)
.where(eq(WorkspaceTable.id, workspaceID))
.then((rows) => rows[0] || null),
),
workspaceID,
)
}, "workspace.get")
const updateWorkspace = action(async (form: FormData) => {
"use server"
const name = form.get("name")?.toString().trim()
if (!name) return { error: "Workspace name is required." }
if (name.length > 255) return { error: "Name must be 255 characters or less." }
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required." }
return json(
await withActor(
() =>
Workspace.update({ name })
.then(() => ({ error: undefined }))
.catch((e) => ({ error: e.message as string })),
workspaceID,
),
)
}, "workspace.update")
export function SettingsSection() {
const params = useParams()
const workspaceInfo = createAsync(() => getWorkspaceInfo(params.id))
const submission = useSubmission(updateWorkspace)
const [store, setStore] = createStore({ show: false })
let input: HTMLInputElement
createEffect(() => {
if (!submission.pending && submission.result && !submission.result.error) {
hide()
}
})
function show() {
while (true) {
submission.clear()
if (!submission.result) break
}
setStore("show", true)
input.focus()
}
function hide() {
setStore("show", false)
}
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>Settings</h2>
<p>Update your workspace name and preferences.</p>
</div>
<div data-slot="section-content">
<div data-slot="setting">
<div data-slot="setting-info">
<h3>Workspace Name</h3>
<p data-slot="current-value">{workspaceInfo()?.name}</p>
</div>
<Show
when={!store.show}
fallback={
<form action={updateWorkspace} method="post" data-slot="create-form">
<div data-slot="input-container">
<input
required
ref={(r) => (input = r)}
data-component="input"
name="name"
type="text"
placeholder="Workspace name"
value={workspaceInfo()?.name ?? "Default"}
/>
<Show when={submission.result && submission.result.error}>
{(err) => <div data-slot="form-error">{err()}</div>}
</Show>
</div>
<input type="hidden" name="workspaceID" value={params.id} />
<div data-slot="form-actions">
<button type="reset" data-color="ghost" onClick={() => hide()}>
Cancel
</button>
<button type="submit" data-color="primary" disabled={submission.pending}>
{submission.pending ? "Updating..." : "Update"}
</button>
</div>
</form>
}
>
<button data-color="primary" onClick={() => show()}>
Edit Name
</button>
</Show>
</div>
</div>
</section>
)
}

View file

@ -0,0 +1 @@
ALTER TABLE `workspace` MODIFY COLUMN `name` varchar(255) NOT NULL;

View file

@ -0,0 +1,709 @@
{
"version": "5",
"dialect": "mysql",
"id": "a331e38c-c2e3-406d-a1ff-b0af7229cd85",
"prevId": "05e873f6-1556-4bcb-8e19-14971e37610a",
"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
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"email": {
"name": "email",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"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_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_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
}
},
"indexes": {
"global_customer_id": {
"name": "global_customer_id",
"columns": [
"customer_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
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"payment_workspace_id_id_pk": {
"name": "payment_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
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"usage_workspace_id_id_pk": {
"name": "usage_workspace_id_id_pk",
"columns": [
"workspace_id",
"id"
]
}
},
"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": {}
},
"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
}
},
"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": {}
}
}

View file

@ -197,6 +197,13 @@
"when": 1759553466608,
"tag": "0027_hot_wong",
"breakpoints": true
},
{
"idx": 28,
"version": "5",
"when": 1759805025276,
"tag": "0028_careful_cerise",
"breakpoints": true
}
]
}

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "0.14.3",
"version": "0.14.5",
"private": true,
"type": "module",
"dependencies": {

View file

@ -1,12 +1,9 @@
import { z } from "zod"
import { and, eq, getTableColumns, isNull } from "drizzle-orm"
import { eq } from "drizzle-orm"
import { fn } from "./util/fn"
import { Database } from "./drizzle"
import { Identifier } from "./identifier"
import { AccountTable } from "./schema/account.sql"
import { Actor } from "./actor"
import { WorkspaceTable } from "./schema/workspace.sql"
import { UserTable } from "./schema/user.sql"
export namespace Account {
export const create = fn(
@ -46,16 +43,4 @@ export namespace Account {
.then((rows) => rows[0])
}),
)
export const workspaces = async () => {
const actor = Actor.assert("account")
return Database.transaction(async (tx) =>
tx
.select(getTableColumns(WorkspaceTable))
.from(WorkspaceTable)
.innerJoin(UserTable, eq(UserTable.workspaceID, WorkspaceTable.id))
.where(and(eq(UserTable.accountID, actor.properties.accountID), isNull(WorkspaceTable.timeDeleted)))
.execute(),
)
}
}

View file

@ -20,6 +20,7 @@ export namespace Actor {
properties: {
userID: string
workspaceID: string
accountID: string
}
}
@ -71,4 +72,12 @@ export namespace Actor {
}
throw new Error(`actor of type "${actor.type}" is not associated with a workspace`)
}
export function account() {
const actor = use()
if ("accountID" in actor.properties) {
return actor.properties.accountID
}
throw new Error(`actor of type "${actor.type}" is not associated with an account`)
}
}

View file

@ -1,4 +1,4 @@
import { primaryKey, mysqlTable, uniqueIndex, varchar, boolean } from "drizzle-orm/mysql-core"
import { primaryKey, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
import { timestamps, ulid } from "../drizzle/types"
export const WorkspaceTable = mysqlTable(
@ -6,7 +6,7 @@ export const WorkspaceTable = mysqlTable(
{
id: ulid("id").notNull().primaryKey(),
slug: varchar("slug", { length: 255 }),
name: varchar("name", { length: 255 }),
name: varchar("name", { length: 255 }).notNull(),
...timestamps,
},
(table) => [uniqueIndex("slug").on(table.slug)],

View file

@ -172,8 +172,6 @@ export namespace User {
),
),
)
return invitations.length
})
export const updateRole = fn(

View file

@ -7,36 +7,69 @@ import { UserTable } from "./schema/user.sql"
import { BillingTable } from "./schema/billing.sql"
import { WorkspaceTable } from "./schema/workspace.sql"
import { Key } from "./key"
import { eq, sql } from "drizzle-orm"
export namespace Workspace {
export const create = fn(z.void(), async () => {
const account = Actor.assert("account")
const workspaceID = Identifier.create("workspace")
const userID = Identifier.create("user")
await Database.transaction(async (tx) => {
await tx.insert(WorkspaceTable).values({
id: workspaceID,
export const create = fn(
z.object({
name: z.string().min(1),
}),
async ({ name }) => {
const account = Actor.assert("account")
const workspaceID = Identifier.create("workspace")
const userID = Identifier.create("user")
await Database.transaction(async (tx) => {
await tx.insert(WorkspaceTable).values({
id: workspaceID,
name,
})
await tx.insert(UserTable).values({
workspaceID,
id: userID,
accountID: account.properties.accountID,
name: "",
role: "admin",
})
await tx.insert(BillingTable).values({
workspaceID,
id: Identifier.create("billing"),
balance: 0,
})
})
await tx.insert(UserTable).values({
workspaceID,
id: userID,
accountID: account.properties.accountID,
name: "",
role: "admin",
})
await tx.insert(BillingTable).values({
workspaceID,
id: Identifier.create("billing"),
balance: 0,
})
})
await Actor.provide(
"system",
{
workspaceID,
},
() => Key.create({ userID, name: "Default API Key" }),
await Actor.provide(
"system",
{
workspaceID,
},
() => Key.create({ userID, name: "Default API Key" }),
)
return workspaceID
},
)
export const update = fn(
z.object({
name: z.string().min(1).max(255),
}),
async ({ name }) => {
const workspaceID = Actor.workspace()
return await Database.use((tx) =>
tx
.update(WorkspaceTable)
.set({
name,
})
.where(eq(WorkspaceTable.id, workspaceID)),
)
},
)
export const remove = fn(z.void(), async () => {
await Database.use((tx) =>
tx
.update(WorkspaceTable)
.set({ timeDeleted: sql`now()` })
.where(eq(WorkspaceTable.id, Actor.workspace())),
)
return workspaceID
})
}

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "0.14.3",
"version": "0.14.5",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View file

@ -11,6 +11,9 @@ import { Workspace } from "@opencode-ai/console-core/workspace.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { Resource } from "@opencode-ai/console-resource"
import { User } from "@opencode-ai/console-core/user.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"
type Env = {
AuthStorage: KVNamespace
@ -123,9 +126,22 @@ export default {
})
}
await Actor.provide("account", { accountID, email }, async () => {
const workspaceCount = await User.joinInvitedWorkspaces()
if (workspaceCount === 0) {
await Workspace.create()
await User.joinInvitedWorkspaces()
const workspaces = await Database.transaction(async (tx) =>
tx
.select({ id: WorkspaceTable.id })
.from(WorkspaceTable)
.innerJoin(UserTable, eq(UserTable.workspaceID, WorkspaceTable.id))
.where(
and(
eq(UserTable.accountID, accountID),
isNull(UserTable.timeDeleted),
isNull(WorkspaceTable.timeDeleted),
),
),
)
if (workspaces.length === 0) {
await Workspace.create({ name: "Default" })
}
})
return ctx.subject("account", accountID, { accountID, email })

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-mail",
"version": "0.14.3",
"version": "0.14.5",
"private": true,
"type": "module",
"dependencies": {

View file

@ -1,20 +1,15 @@
{
"name": "@opencode-ai/console-scripts",
"version": "0.14.3",
"version": "0.14.5",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
"scripts": {
"shell": "sst shell -- bun tsx",
"shell-dev": "sst shell --stage dev -- bun tsx",
"shell-prod": "sst shell --stage production -- bun tsx"
"shell": "sst shell -- bun",
"shell-dev": "sst shell --stage dev -- bun",
"shell-prod": "sst shell --stage production -- bun"
},
"dependencies": {
"@opencode-ai/console-core": "workspace:*",
"tsx": "4.20.5"
},
"devDependencies": {
"@types/node": "catalog:",
"typescript": "catalog:"
"@opencode-ai/console-core": "workspace:*"
}
}

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/desktop",
"version": "0.14.3",
"version": "0.14.5",
"description": "",
"type": "module",
"scripts": {

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "0.14.3",
"version": "0.14.5",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "0.14.3",
"version": "0.14.5",
"name": "opencode",
"type": "module",
"private": true,

View file

@ -106,7 +106,7 @@ export const RunCommand = cmd({
if (args.session) return Session.get(args.session)
return Session.create()
return Session.create({})
})()
if (!session) {

View file

@ -9,6 +9,7 @@ import z from "zod/v4"
import { Session } from "../session"
import { Bus } from "../bus"
import { Instance } from "../project/instance"
import { withTimeout } from "@/util/timeout"
export namespace MCP {
const log = Log.create({ service: "mcp" })
@ -20,11 +21,13 @@ export namespace MCP {
}),
)
type MCPClient = Awaited<ReturnType<typeof experimental_createMCPClient>>
const state = Instance.state(
async () => {
const cfg = await Config.get()
const clients: {
[name: string]: Awaited<ReturnType<typeof experimental_createMCPClient>>
[name: string]: MCPClient
} = {}
for (const [key, mcp] of Object.entries(cfg.mcp ?? {})) {
if (mcp.enabled === false) {
@ -135,8 +138,17 @@ export namespace MCP {
}
}
for (const [key, client] of Object.entries(clients)) {
const result = await withTimeout(client.tools(), 5000).catch(() => {})
if (!result) {
log.warn("mcp client verification failed, removing client", { key })
delete clients[key]
}
}
return {
clients,
config: cfg.mcp ?? {},
}
},
async (state) => {
@ -146,6 +158,23 @@ export namespace MCP {
},
)
export async function status() {
return state().then((state) => {
const result: Record<string, "connected" | "failed" | "disabled"> = {}
for (const [key, client] of Object.entries(state.config)) {
if (client.enabled === false) {
result[key] = "disabled"
continue
}
if (state.clients[key]) {
result[key] = "connected"
}
result[key] = "failed"
}
return result
})
}
export async function clients() {
return state().then((state) => state.clients)
}

View file

@ -31,7 +31,7 @@ import { SessionRevert } from "../session/revert"
import { lazy } from "../util/lazy"
import { Todo } from "../session/todo"
import { InstanceBootstrap } from "../project/bootstrap"
import { Identifier } from "@/id/id"
import { MCP } from "../mcp"
const ERRORS = {
400: {
@ -308,7 +308,7 @@ export namespace Server {
validator(
"param",
z.object({
id: z.string(),
id: Session.get.schema,
}),
),
async (c) => {
@ -336,7 +336,7 @@ export namespace Server {
validator(
"param",
z.object({
id: z.string(),
id: Session.children.schema,
}),
),
async (c) => {
@ -390,18 +390,10 @@ export namespace Server {
},
},
}),
validator(
"json",
z
.object({
parentID: z.string().optional(),
title: z.string().optional(),
})
.optional(),
),
validator("json", Session.create.schema.optional()),
async (c) => {
const body = c.req.valid("json") ?? {}
const session = await Session.create(body.parentID, body.title)
const session = await Session.create(body)
return c.json(session)
},
)
@ -424,7 +416,7 @@ export namespace Server {
validator(
"param",
z.object({
id: z.string(),
id: Session.remove.schema,
}),
),
async (c) => {
@ -495,14 +487,7 @@ export namespace Server {
id: z.string().meta({ description: "Session ID" }),
}),
),
validator(
"json",
z.object({
messageID: z.string(),
providerID: z.string(),
modelID: z.string(),
}),
),
validator("json", Session.initialize.schema.omit({ sessionID: true })),
async (c) => {
const sessionID = c.req.valid("param").id
const body = c.req.valid("json")
@ -529,7 +514,7 @@ export namespace Server {
validator(
"param",
z.object({
id: Identifier.schema("session").meta({ description: "Session ID" }),
id: Session.fork.schema.shape.sessionID,
}),
),
validator("json", Session.fork.schema.omit({ sessionID: true })),
@ -614,7 +599,7 @@ export namespace Server {
validator(
"param",
z.object({
id: z.string(),
id: Session.unshare.schema,
}),
),
async (c) => {
@ -717,7 +702,7 @@ export namespace Server {
),
async (c) => {
const params = c.req.valid("param")
const message = await Session.getMessage(params.id, params.messageID)
const message = await Session.getMessage({ sessionID: params.id, messageID: params.messageID })
return c.json(message)
},
)
@ -1199,6 +1184,26 @@ export namespace Server {
return c.json(modes)
},
)
.get(
"/mcp",
describeRoute({
description: "Get MCP server status",
operationId: "mcp.status",
responses: {
200: {
description: "MCP server status",
content: {
"application/json": {
schema: resolver(z.any()),
},
},
},
},
}),
async (c) => {
return c.json(await MCP.status())
},
)
.post(
"/tui/append-prompt",
describeRoute({

View file

@ -145,7 +145,7 @@ export namespace SessionCompaction {
},
],
})
const usage = Session.getUsage(model.info, generated.usage, generated.providerMetadata)
const usage = Session.getUsage({ model: model.info, usage: generated.usage, metadata: generated.providerMetadata })
msg.cost += usage.cost
msg.tokens = usage.tokens
msg.summary = true

View file

@ -93,13 +93,21 @@ export namespace Session {
),
}
export async function create(parentID?: string, title?: string) {
return createNext({
parentID,
directory: Instance.directory,
title,
})
}
export const create = fn(
z
.object({
parentID: Identifier.schema("session").optional(),
title: z.string().optional(),
})
.optional(),
async (input) => {
return createNext({
parentID: input?.parentID,
directory: Instance.directory,
title: input?.title,
})
},
)
export const fork = fn(
z.object({
@ -132,11 +140,11 @@ export namespace Session {
},
)
export async function touch(sessionID: string) {
export const touch = fn(Identifier.schema("session"), async (sessionID) => {
await update(sessionID, (draft) => {
draft.time.updated = Date.now()
})
}
})
export async function createNext(input: { id?: string; title?: string; parentID?: string; directory: string }) {
const result: Info = {
@ -170,16 +178,16 @@ export namespace Session {
return result
}
export async function get(id: string) {
export const get = fn(Identifier.schema("session"), async (id) => {
const read = await Storage.read<Info>(["session", Instance.project.id, id])
return read as Info
}
})
export async function getShare(id: string) {
export const getShare = fn(Identifier.schema("session"), async (id) => {
return Storage.read<ShareInfo>(["share", id])
}
})
export async function share(id: string) {
export const share = fn(Identifier.schema("session"), async (id) => {
const cfg = await Config.get()
if (cfg.share === "disabled") {
throw new Error("Sharing is disabled in configuration")
@ -202,9 +210,9 @@ export namespace Session {
}
}
return share
}
})
export async function unshare(id: string) {
export const unshare = fn(Identifier.schema("session"), async (id) => {
const share = await getShare(id)
if (!share) return
await Storage.remove(["share", id])
@ -212,7 +220,7 @@ export namespace Session {
draft.share = undefined
})
await Share.remove(id, share.secret)
}
})
export async function update(id: string, editor: (session: Info) => void) {
const project = Instance.project
@ -226,7 +234,7 @@ export namespace Session {
return result
}
export async function messages(sessionID: string) {
export const messages = fn(Identifier.schema("session"), async (sessionID) => {
const result = [] as MessageV2.WithParts[]
for (const p of await Storage.list(["message", sessionID])) {
const read = await Storage.read<MessageV2.Info>(p)
@ -237,16 +245,22 @@ export namespace Session {
}
result.sort((a, b) => (a.info.id > b.info.id ? 1 : -1))
return result
}
})
export async function getMessage(sessionID: string, messageID: string) {
return {
info: await Storage.read<MessageV2.Info>(["message", sessionID, messageID]),
parts: await getParts(messageID),
}
}
export const getMessage = fn(
z.object({
sessionID: Identifier.schema("session"),
messageID: Identifier.schema("message"),
}),
async (input) => {
return {
info: await Storage.read<MessageV2.Info>(["message", input.sessionID, input.messageID]),
parts: await getParts(input.messageID),
}
},
)
export async function getParts(messageID: string) {
export const getParts = fn(Identifier.schema("message"), async (messageID) => {
const result = [] as MessageV2.Part[]
for (const item of await Storage.list(["part", messageID])) {
const read = await Storage.read<MessageV2.Part>(item)
@ -254,7 +268,7 @@ export namespace Session {
}
result.sort((a, b) => (a.id > b.id ? 1 : -1))
return result
}
})
export async function* list() {
const project = Instance.project
@ -263,7 +277,7 @@ export namespace Session {
}
}
export async function children(parentID: string) {
export const children = fn(Identifier.schema("session"), async (parentID) => {
const project = Instance.project
const result = [] as Session.Info[]
for (const item of await Storage.list(["session", project.id])) {
@ -272,9 +286,9 @@ export namespace Session {
result.push(session)
}
return result
}
})
export async function remove(sessionID: string) {
export const remove = fn(Identifier.schema("session"), async (sessionID) => {
const project = Instance.project
try {
const session = await get(sessionID)
@ -295,56 +309,69 @@ export namespace Session {
} catch (e) {
log.error(e)
}
}
})
export async function updateMessage(msg: MessageV2.Info) {
export const updateMessage = fn(MessageV2.Info, async (msg) => {
await Storage.write(["message", msg.sessionID, msg.id], msg)
Bus.publish(MessageV2.Event.Updated, {
info: msg,
})
return msg
}
})
export async function removeMessage(sessionID: string, messageID: string) {
await Storage.remove(["message", sessionID, messageID])
Bus.publish(MessageV2.Event.Removed, {
sessionID,
messageID,
})
return messageID
}
export const removeMessage = fn(
z.object({
sessionID: Identifier.schema("session"),
messageID: Identifier.schema("message"),
}),
async (input) => {
await Storage.remove(["message", input.sessionID, input.messageID])
Bus.publish(MessageV2.Event.Removed, {
sessionID: input.sessionID,
messageID: input.messageID,
})
return input.messageID
},
)
export async function updatePart(part: MessageV2.Part) {
export const updatePart = fn(MessageV2.Part, async (part) => {
await Storage.write(["part", part.messageID, part.id], part)
Bus.publish(MessageV2.Event.PartUpdated, {
part,
})
return part
}
})
export function getUsage(model: ModelsDev.Model, usage: LanguageModelUsage, metadata?: ProviderMetadata) {
const tokens = {
input: usage.inputTokens ?? 0,
output: usage.outputTokens ?? 0,
reasoning: usage?.reasoningTokens ?? 0,
cache: {
write: (metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
// @ts-expect-error
metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
0) as number,
read: usage.cachedInputTokens ?? 0,
},
}
return {
cost: new Decimal(0)
.add(new Decimal(tokens.input).mul(model.cost?.input ?? 0).div(1_000_000))
.add(new Decimal(tokens.output).mul(model.cost?.output ?? 0).div(1_000_000))
.add(new Decimal(tokens.cache.read).mul(model.cost?.cache_read ?? 0).div(1_000_000))
.add(new Decimal(tokens.cache.write).mul(model.cost?.cache_write ?? 0).div(1_000_000))
.toNumber(),
tokens,
}
}
export const getUsage = fn(
z.object({
model: z.custom<ModelsDev.Model>(),
usage: z.custom<LanguageModelUsage>(),
metadata: z.custom<ProviderMetadata>().optional(),
}),
(input) => {
const tokens = {
input: input.usage.inputTokens ?? 0,
output: input.usage.outputTokens ?? 0,
reasoning: input.usage?.reasoningTokens ?? 0,
cache: {
write: (input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
// @ts-expect-error
input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
0) as number,
read: input.usage.cachedInputTokens ?? 0,
},
}
return {
cost: new Decimal(0)
.add(new Decimal(tokens.input).mul(input.model.cost?.input ?? 0).div(1_000_000))
.add(new Decimal(tokens.output).mul(input.model.cost?.output ?? 0).div(1_000_000))
.add(new Decimal(tokens.cache.read).mul(input.model.cost?.cache_read ?? 0).div(1_000_000))
.add(new Decimal(tokens.cache.write).mul(input.model.cost?.cache_write ?? 0).div(1_000_000))
.toNumber(),
tokens,
}
},
)
export class BusyError extends Error {
constructor(public readonly sessionID: string) {
@ -352,27 +379,30 @@ export namespace Session {
}
}
export async function initialize(input: {
sessionID: string
modelID: string
providerID: string
messageID: string
}) {
await SessionPrompt.prompt({
sessionID: input.sessionID,
messageID: input.messageID,
model: {
providerID: input.providerID,
modelID: input.modelID,
},
parts: [
{
id: Identifier.ascending("part"),
type: "text",
text: PROMPT_INITIALIZE.replace("${path}", Instance.worktree),
export const initialize = fn(
z.object({
sessionID: Identifier.schema("session"),
modelID: z.string(),
providerID: z.string(),
messageID: Identifier.schema("message"),
}),
async (input) => {
await SessionPrompt.prompt({
sessionID: input.sessionID,
messageID: input.messageID,
model: {
providerID: input.providerID,
modelID: input.modelID,
},
],
})
await Project.setInitialized(Instance.project.id)
}
parts: [
{
id: Identifier.ascending("part"),
type: "text",
text: PROMPT_INITIALIZE.replace("${path}", Instance.worktree),
},
],
})
await Project.setInitialized(Instance.project.id)
},
)
}

View file

@ -528,6 +528,8 @@ export namespace SessionPrompt {
)
return {
title: "",
metadata: {},
output,
}
}
@ -1027,7 +1029,11 @@ export namespace SessionPrompt {
break
case "finish-step":
const usage = Session.getUsage(input.model, value.usage, value.providerMetadata)
const usage = Session.getUsage({
model: input.model,
usage: value.usage,
metadata: value.providerMetadata,
})
assistantMsg.cost += usage.cost
assistantMsg.tokens = usage.tokens
await Session.updateMessage(assistantMsg)

View file

@ -20,12 +20,11 @@ export const GrepTool = Tool.define("grep", {
const searchPath = params.path || Instance.directory
const rgPath = await Ripgrep.filepath()
const args = ["-n", params.pattern]
const args = ["-nH", "--field-match-separator=|", params.pattern]
if (params.include) {
args.push("--glob", params.include)
}
args.push(searchPath)
args.push("--field-match-separator=|")
const proc = Bun.spawn([rgPath, ...args], {
stdout: "pipe",

View file

@ -26,8 +26,11 @@ export const TaskTool = Tool.define("task", async () => {
async execute(params, ctx) {
const agent = await Agent.get(params.subagent_type)
if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)
const session = await Session.create(ctx.sessionID, params.description + ` (@${agent.name} subagent)`)
const msg = await Session.getMessage(ctx.sessionID, ctx.messageID)
const session = await Session.create({
parentID: ctx.sessionID,
title: params.description + ` (@${agent.name} subagent)`,
})
const msg = await Session.getMessage({ sessionID: ctx.sessionID, messageID: ctx.messageID })
if (msg.info.role !== "assistant") throw new Error("Not an assistant message")
const messageID = Identifier.ascending("message")
const parts: Record<string, MessageV2.ToolPart> = {}

View file

@ -1,75 +0,0 @@
const parser = async () => {
try {
const { default: Parser } = await import("tree-sitter")
const Bash = await import("tree-sitter-bash")
const p = new Parser()
p.setLanguage(Bash.language as any)
return p
} catch (e) {
const { Parser, Language } = await import("web-tree-sitter")
const { default: treeWasm } = await import("web-tree-sitter/web-tree-sitter.wasm" as string, {
with: { type: "wasm" },
})
await Parser.init({
locateFile() {
return treeWasm
},
})
const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, {
with: { type: "wasm" },
})
const bashLanguage = await Language.load(bashWasm)
const p = new Parser()
p.setLanguage(bashLanguage)
return p
}
}
const sourceCode = `cd --foo foo/bar && echo "hello" && cd ../baz`
const tree = await parser().then((p) => p.parse(sourceCode))
// Function to extract commands and arguments
function extractCommands(node: any): Array<{ command: string; args: string[] }> {
const commands: Array<{ command: string; args: string[] }> = []
function traverse(node: any) {
if (node.type === "command") {
const commandNode = node.child(0)
if (commandNode) {
const command = commandNode.text
const args: string[] = []
// Extract arguments
for (let i = 1; i < node.childCount; i++) {
const child = node.child(i)
if (child && child.type === "word") {
args.push(child.text)
}
}
commands.push({ command, args })
}
}
// Traverse children
for (let i = 0; i < node.childCount; i++) {
traverse(node.child(i))
}
}
traverse(node)
return commands
}
// Extract and display commands
console.log("Source code: " + sourceCode)
if (!tree) {
throw new Error("Failed to parse command")
}
const commands = extractCommands(tree.rootNode)
console.log("Extracted commands:")
commands.forEach((cmd, index) => {
console.log(`${index + 1}. Command: ${cmd.command}`)
console.log(` Args: [${cmd.args.join(", ")}]`)
})

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "0.14.3",
"version": "0.14.5",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "0.14.3",
"version": "0.14.5",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",

View file

@ -82,6 +82,8 @@ import type {
AppLogResponses,
AppAgentsData,
AppAgentsResponses,
McpStatusData,
McpStatusResponses,
TuiAppendPromptData,
TuiAppendPromptResponses,
TuiOpenHelpData,
@ -567,6 +569,18 @@ class App extends _HeyApiClient {
}
}
class Mcp extends _HeyApiClient {
/**
* Get MCP server status
*/
public status<ThrowOnError extends boolean = false>(options?: Options<McpStatusData, ThrowOnError>) {
return (options?.client ?? this._client).get<McpStatusResponses, unknown, ThrowOnError>({
url: "/mcp",
...options,
})
}
}
class Tui extends _HeyApiClient {
/**
* Append prompt to the TUI
@ -724,6 +738,7 @@ export class OpencodeClient extends _HeyApiClient {
find = new Find({ client: this._client })
file = new File({ client: this._client })
app = new App({ client: this._client })
mcp = new Mcp({ client: this._client })
tui = new Tui({ client: this._client })
auth = new Auth({ client: this._client })
event = new Event({ client: this._client })

View file

@ -1496,9 +1496,9 @@ export type SessionTodoResponse = SessionTodoResponses[keyof SessionTodoResponse
export type SessionInitData = {
body?: {
messageID: string
providerID: string
modelID: string
providerID: string
messageID: string
}
path: {
/**
@ -1526,9 +1526,12 @@ export type SessionForkData = {
messageID?: string
}
path: {
<<<<<<< HEAD
/**
* Session ID
*/
=======
>>>>>>> dev
id: string
}
query?: {
@ -2076,6 +2079,22 @@ export type AppAgentsResponses = {
export type AppAgentsResponse = AppAgentsResponses[keyof AppAgentsResponses]
export type McpStatusData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/mcp"
}
export type McpStatusResponses = {
/**
* MCP server status
*/
200: unknown
}
export type TuiAppendPromptData = {
body?: {
text: string

View file

@ -1,7 +1,7 @@
{
"name": "@opencode-ai/web",
"type": "module",
"version": "0.14.3",
"version": "0.14.5",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
@ -31,7 +31,7 @@
"sharp": "0.32.5",
"shiki": "3.4.2",
"solid-js": "catalog:",
"toolbeam-docs-theme": "0.4.6"
"toolbeam-docs-theme": "0.4.7"
},
"devDependencies": {
"opencode": "workspace:*",

View file

@ -69,6 +69,23 @@ Your editor should be able to validate and autocomplete based on the schema.
---
### TUI
You can configure TUI-specific settings through the `tui` option.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"tui": {
"scroll_speed": 3
}
}
```
[Learn more about using the TUI here](/docs/tui).
---
### Models
You can configure the providers and models you want to use in your OpenCode config through the `provider`, `model` and `small_model` options.
@ -204,23 +221,6 @@ OpenCode will automatically download any new updates when it starts up. You can
---
### TUI
You can configure TUI-specific settings through the `tui` option.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"tui": {
"scroll_speed": 3
}
}
```
[Learn more about using the TUI here](/docs/tui).
---
### Formatters
You can configure code formatters through the `formatter` option.

View file

@ -73,6 +73,10 @@ You can also access our models through the following API endpoints.
| Grok Code Fast 1 | grok-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2 | kimi-k2 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
The [model id](/docs/config/#models) in your OpenCode config
uses the format `opencode/<model-id>`. For example, for GPT 5 Codex, you would
use `opencode/gpt-5-codex` in your config.
---
## Pricing
@ -94,6 +98,8 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
| GPT 5 | $1.25 | $10.00 | $0.125 | - |
| GPT 5 Codex | $1.25 | $10.00 | $0.125 | - |
You might notice _Claude Haiku 3.5_ in your usage history. This is a [low cost model](/docs/config/#models) that's used to generate the titles of your sessions.
:::note
Credit card fees are passed along at cost; we don't charge anything beyond that.
:::

View file

@ -24,6 +24,10 @@
--color-border-weak: hsl(0, 1%, 85%);
--color-icon: hsl(0, 1%, 55%);
/* For the share component */
--sl-color-bg-surface: var(--sl-color-bg-nav);
--sl-color-divider: var(--sl-color-gray-5);
}
@ -294,14 +298,16 @@ body > .page > header, :root[data-has-sidebar] body > .page > header {
}
.sl-container ul li a {
nav.sidebar .sl-container ul li a,
div.right-sidebar .sl-container ul li a {
padding: 4px 24px !important;
width: 100% !important;
color: var(--color-text-weaker);
opacity: 50%;
}
.sl-container ul li a:hover {
nav.sidebar .sl-container ul li a:hover,
div.right-sidebar .sl-container ul li a:hover {
background: var(--color-background-weak);
@media (prefers-color-scheme: dark) {
@ -309,12 +315,14 @@ body > .page > header, :root[data-has-sidebar] body > .page > header {
}
}
.sl-container ul li ul li {
nav.sidebar .sl-container ul li ul li,
div.right-sidebar .sl-container ul li ul li {
padding: 4px 12px 0 12px !important;
}
.sl-container ul li a[aria-current="true"] {
nav.sidebar .sl-container ul li a[aria-current="true"],
div.right-sidebar .sl-container ul li a[aria-current="true"] {
color: var(--color-text-strong) !important;
opacity: 100%;
}

View file

@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "0.14.3",
"version": "0.14.5",
"publisher": "sst-dev",
"repository": {
"type": "git",