From 626fa1462bb139d02a9d34a5864af8bd746aa2da Mon Sep 17 00:00:00 2001 From: Ariane Emory <97994360+ariane-emory@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:55:46 -0500 Subject: [PATCH 001/104] fix: make home/end keys work in menu list modal windows (resolves #7190) (#8347) --- packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index f7d7306d015..f1cdaaa5292 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -161,6 +161,8 @@ export function DialogSelect(props: DialogSelectProps) { if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1) if (evt.name === "pageup") move(-10) if (evt.name === "pagedown") move(10) + if (evt.name === "home") moveTo(0) + if (evt.name === "end") moveTo(flat().length - 1) if (evt.name === "return") { const option = selected() if (option) { From 578239e0d081372b4d0fdc7c7cb8a5e54b56e0ca Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 16 Jan 2026 16:13:28 -0600 Subject: [PATCH 002/104] chore: cleanup transform code a tad --- packages/opencode/src/provider/transform.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 4566fc1de2b..023b00dd304 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -26,9 +26,7 @@ export namespace ProviderTransform { msgs = msgs.map((msg) => { if (msg.providerOptions) { for (const options of Object.values(msg.providerOptions)) { - if (options && typeof options === "object") { - delete options["itemId"] - } + delete options["itemId"] } } if (!Array.isArray(msg.content)) { @@ -37,9 +35,7 @@ export namespace ProviderTransform { const content = msg.content.map((part) => { if (part.providerOptions) { for (const options of Object.values(part.providerOptions)) { - if (options && typeof options === "object") { - delete options["itemId"] - } + delete options["itemId"] } } return part From 8cddc9ea555039743c210392b63550781810f8ac Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 16 Jan 2026 22:14:23 +0000 Subject: [PATCH 003/104] chore: generate --- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 7ddfca3168a..27349018083 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -25,4 +25,4 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:" } -} \ No newline at end of file +} diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index e5dde4633ba..f3b12aa8c9f 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -30,4 +30,4 @@ "publishConfig": { "directory": "dist" } -} \ No newline at end of file +} From 08b94a6890276d1e654696c3be3274521d99b508 Mon Sep 17 00:00:00 2001 From: Seth Carlton Date: Fri, 16 Jan 2026 16:19:17 -0600 Subject: [PATCH 004/104] fix: keep primary model after subagent runs (#8951) --- packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 96b9e8ffd57..145fa9da0c3 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -145,9 +145,9 @@ export function Prompt(props: PromptProps) { const isPrimaryAgent = local.agent.list().some((x) => x.name === msg.agent) if (msg.agent && isPrimaryAgent) { local.agent.set(msg.agent) + if (msg.model) local.model.set(msg.model) + if (msg.variant) local.model.variant.set(msg.variant) } - if (msg.model) local.model.set(msg.model) - if (msg.variant) local.model.variant.set(msg.variant) } }) From c25155586cd57b8db68a479c714c44f2bfbf667b Mon Sep 17 00:00:00 2001 From: Akshar Patel <123344143+AksharP5@users.noreply.github.com> Date: Fri, 16 Jan 2026 17:42:27 -0500 Subject: [PATCH 005/104] fix: open help dialog with tui/open-help route (#8596) --- packages/opencode/src/server/routes/tui.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/server/routes/tui.ts b/packages/opencode/src/server/routes/tui.ts index be371c1e09e..0577429dd74 100644 --- a/packages/opencode/src/server/routes/tui.ts +++ b/packages/opencode/src/server/routes/tui.ts @@ -119,7 +119,9 @@ export const TuiRoutes = lazy(() => }, }), async (c) => { - // TODO: open dialog + await Bus.publish(TuiEvent.CommandExecute, { + command: "help.show", + }) return c.json(true) }, ) From cbe1c81470564a8f5042047d7422219426e78030 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 16 Jan 2026 17:46:32 -0500 Subject: [PATCH 006/104] wip: black --- .../console/app/src/routes/stripe/webhook.ts | 9 +- .../migrations/0053_gigantic_hardball.sql | 1 + .../core/migrations/meta/0053_snapshot.json | 1316 +++++++++++++++++ .../core/migrations/meta/_journal.json | 9 +- packages/console/core/script/black-gift.ts | 112 ++ ...{onboard-zen-black.ts => black-onboard.ts} | 11 +- .../console/core/script/black-transfer.ts | 6 +- packages/console/core/script/lookup-user.ts | 18 +- .../console/core/src/schema/billing.sql.ts | 13 +- 9 files changed, 1474 insertions(+), 21 deletions(-) create mode 100644 packages/console/core/migrations/0053_gigantic_hardball.sql create mode 100644 packages/console/core/migrations/meta/0053_snapshot.json create mode 100644 packages/console/core/script/black-gift.ts rename packages/console/core/script/{onboard-zen-black.ts => black-onboard.ts} (95%) diff --git a/packages/console/app/src/routes/stripe/webhook.ts b/packages/console/app/src/routes/stripe/webhook.ts index 3422d9dd65d..4c3430193e4 100644 --- a/packages/console/app/src/routes/stripe/webhook.ts +++ b/packages/console/app/src/routes/stripe/webhook.ts @@ -183,7 +183,12 @@ export async function POST(input: APIEvent) { .set({ customerID, subscriptionID, - subscriptionCouponID: couponID, + subscription: { + status: "subscribed", + coupon: couponID, + seats: 1, + plan: "200", + }, paymentMethodID: paymentMethod.id, paymentMethodLast4: paymentMethod.card?.last4 ?? null, paymentMethodType: paymentMethod.type, @@ -408,7 +413,7 @@ export async function POST(input: APIEvent) { await Database.transaction(async (tx) => { await tx .update(BillingTable) - .set({ subscriptionID: null, subscriptionCouponID: null }) + .set({ subscriptionID: null, subscription: null }) .where(eq(BillingTable.workspaceID, workspaceID)) await tx.delete(SubscriptionTable).where(eq(SubscriptionTable.workspaceID, workspaceID)) diff --git a/packages/console/core/migrations/0053_gigantic_hardball.sql b/packages/console/core/migrations/0053_gigantic_hardball.sql new file mode 100644 index 00000000000..72d43135f44 --- /dev/null +++ b/packages/console/core/migrations/0053_gigantic_hardball.sql @@ -0,0 +1 @@ +ALTER TABLE `billing` ADD `subscription` json; \ No newline at end of file diff --git a/packages/console/core/migrations/meta/0053_snapshot.json b/packages/console/core/migrations/meta/0053_snapshot.json new file mode 100644 index 00000000000..fba46b14b6e --- /dev/null +++ b/packages/console/core/migrations/meta/0053_snapshot.json @@ -0,0 +1,1316 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "32a0c40b-a269-4ad1-a5a0-52b1f18932aa", + "prevId": "00774acd-a1e5-49c0-b296-cacc9506a566", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "account_id_pk": { + "name": "account_id_pk", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "auth": { + "name": "auth", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "enum('email','github','google')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "provider": { + "name": "provider", + "columns": [ + "provider", + "subject" + ], + "isUnique": true + }, + "account_id": { + "name": "account_id", + "columns": [ + "account_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "auth_id_pk": { + "name": "auth_id_pk", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "benchmark": { + "name": "benchmark", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent": { + "name": "agent", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "time_created": { + "name": "time_created", + "columns": [ + "time_created" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "benchmark_id_pk": { + "name": "benchmark_id_pk", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "billing": { + "name": "billing", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_type": { + "name": "payment_method_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_last4": { + "name": "payment_method_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "balance": { + "name": "balance", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_usage": { + "name": "monthly_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_monthly_usage_updated": { + "name": "time_monthly_usage_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload": { + "name": "reload", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_trigger": { + "name": "reload_trigger", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_amount": { + "name": "reload_amount", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_error": { + "name": "reload_error", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_error": { + "name": "time_reload_error", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_locked_till": { + "name": "time_reload_locked_till", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription": { + "name": "subscription", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "varchar(28)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_coupon_id": { + "name": "subscription_coupon_id", + "type": "varchar(28)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_plan": { + "name": "subscription_plan", + "type": "enum('20','100','200')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_subscription_booked": { + "name": "time_subscription_booked", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_customer_id": { + "name": "global_customer_id", + "columns": [ + "customer_id" + ], + "isUnique": true + }, + "global_subscription_id": { + "name": "global_subscription_id", + "columns": [ + "subscription_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "billing_workspace_id_id_pk": { + "name": "billing_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "payment": { + "name": "payment", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoice_id": { + "name": "invoice_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_id": { + "name": "payment_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_refunded": { + "name": "time_refunded", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enrichment": { + "name": "enrichment", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "payment_workspace_id_id_pk": { + "name": "payment_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "subscription": { + "name": "subscription", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rolling_usage": { + "name": "rolling_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fixed_usage": { + "name": "fixed_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_rolling_updated": { + "name": "time_rolling_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_fixed_updated": { + "name": "time_fixed_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspace_user_id": { + "name": "workspace_user_id", + "columns": [ + "workspace_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "subscription_workspace_id_id_pk": { + "name": "subscription_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "usage": { + "name": "usage", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_5m_tokens": { + "name": "cache_write_5m_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_1h_tokens": { + "name": "cache_write_1h_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost": { + "name": "cost", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_id": { + "name": "key_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enrichment": { + "name": "enrichment", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "usage_time_created": { + "name": "usage_time_created", + "columns": [ + "workspace_id", + "time_created" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_workspace_id_id_pk": { + "name": "usage_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ip_rate_limit": { + "name": "ip_rate_limit", + "columns": { + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "interval": { + "name": "interval", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ip_rate_limit_ip_interval_pk": { + "name": "ip_rate_limit_ip_interval_pk", + "columns": [ + "ip", + "interval" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ip": { + "name": "ip", + "columns": { + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage": { + "name": "usage", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ip_ip_pk": { + "name": "ip_ip_pk", + "columns": [ + "ip" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "key": { + "name": "key", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_used": { + "name": "time_used", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_key": { + "name": "global_key", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "key_workspace_id_id_pk": { + "name": "key_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "model": { + "name": "model", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "model_workspace_model": { + "name": "model_workspace_model", + "columns": [ + "workspace_id", + "model" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "model_workspace_id_id_pk": { + "name": "model_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "provider": { + "name": "provider", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_provider": { + "name": "workspace_provider", + "columns": [ + "workspace_id", + "provider" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "provider_workspace_id_id_pk": { + "name": "provider_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_seen": { + "name": "time_seen", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('admin','member')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_usage": { + "name": "monthly_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_monthly_usage_updated": { + "name": "time_monthly_usage_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_account_id": { + "name": "user_account_id", + "columns": [ + "workspace_id", + "account_id" + ], + "isUnique": true + }, + "user_email": { + "name": "user_email", + "columns": [ + "workspace_id", + "email" + ], + "isUnique": true + }, + "global_account_id": { + "name": "global_account_id", + "columns": [ + "account_id" + ], + "isUnique": false + }, + "global_email": { + "name": "global_email", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_workspace_id_id_pk": { + "name": "user_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "workspace": { + "name": "workspace", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "slug": { + "name": "slug", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "workspace_id": { + "name": "workspace_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/console/core/migrations/meta/_journal.json b/packages/console/core/migrations/meta/_journal.json index cdf4f63906d..89ffd242376 100644 --- a/packages/console/core/migrations/meta/_journal.json +++ b/packages/console/core/migrations/meta/_journal.json @@ -372,6 +372,13 @@ "when": 1768343920467, "tag": "0052_aromatic_agent_zero", "breakpoints": true + }, + { + "idx": 53, + "version": "5", + "when": 1768599366758, + "tag": "0053_gigantic_hardball", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/console/core/script/black-gift.ts b/packages/console/core/script/black-gift.ts new file mode 100644 index 00000000000..3fbf210ab5c --- /dev/null +++ b/packages/console/core/script/black-gift.ts @@ -0,0 +1,112 @@ +import { Billing } from "../src/billing.js" +import { and, Database, eq, isNull, sql } from "../src/drizzle/index.js" +import { UserTable } from "../src/schema/user.sql.js" +import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js" +import { Identifier } from "../src/identifier.js" +import { centsToMicroCents } from "../src/util/price.js" +import { AuthTable } from "../src/schema/auth.sql.js" + +const plan = "200" +const workspaceID = process.argv[2] +const seats = parseInt(process.argv[3]) + +console.log(`Gifting ${seats} seats of Black to workspace ${workspaceID}`) + +if (!workspaceID || !seats) throw new Error("Usage: bun foo.ts ") + +// Get workspace user +const users = await Database.use((tx) => + tx + .select({ + id: UserTable.id, + role: UserTable.role, + email: AuthTable.subject, + }) + .from(UserTable) + .leftJoin(AuthTable, and(eq(AuthTable.accountID, UserTable.accountID), eq(AuthTable.provider, "email"))) + .where(and(eq(UserTable.workspaceID, workspaceID), isNull(UserTable.timeDeleted))), +) +if (users.length === 0) throw new Error(`Error: No users found in workspace ${workspaceID}`) +if (users.length !== seats) + throw new Error(`Error: Workspace ${workspaceID} has ${users.length} users, expected ${seats}`) +const adminUser = users.find((user) => user.role === "admin") +if (!adminUser) throw new Error(`Error: No admin user found in workspace ${workspaceID}`) +if (!adminUser.email) throw new Error(`Error: Admin user ${adminUser.id} has no email`) + +// Get Billing +const billing = await Database.use((tx) => + tx + .select({ + customerID: BillingTable.customerID, + subscriptionID: BillingTable.subscriptionID, + }) + .from(BillingTable) + .where(eq(BillingTable.workspaceID, workspaceID)) + .then((rows) => rows[0]), +) +if (!billing) throw new Error(`Error: Workspace ${workspaceID} has no billing record`) +if (billing.subscriptionID) throw new Error(`Error: Workspace ${workspaceID} already has a subscription`) + +// Look up the Stripe customer by email +const customerID = + billing.customerID ?? + (await (() => + Billing.stripe() + .customers.create({ + email: adminUser.email, + metadata: { + workspaceID, + }, + }) + .then((customer) => customer.id))()) +console.log(`Customer ID: ${customerID}`) + +const couponID = "JAIr0Pe1" +const subscription = await Billing.stripe().subscriptions.create({ + customer: customerID!, + items: [ + { + price: `price_1SmfyI2StuRr0lbXovxJNeZn`, + discounts: [{ coupon: couponID }], + quantity: 2, + }, + ], +}) +console.log(`Subscription ID: ${subscription.id}`) + +await Database.transaction(async (tx) => { + // Set customer id, subscription id, and payment method on workspace billing + await tx + .update(BillingTable) + .set({ + customerID, + subscriptionID: subscription.id, + subscription: { status: "subscribed", coupon: couponID, seats, plan }, + }) + .where(eq(BillingTable.workspaceID, workspaceID)) + + // Create a row in subscription table + for (const user of users) { + await tx.insert(SubscriptionTable).values({ + workspaceID, + id: Identifier.create("subscription"), + userID: user.id, + }) + } + // + // // Create a row in payments table + // await tx.insert(PaymentTable).values({ + // workspaceID, + // id: Identifier.create("payment"), + // amount: centsToMicroCents(amountInCents), + // customerID, + // invoiceID, + // paymentID, + // enrichment: { + // type: "subscription", + // couponID, + // }, + // }) +}) + +console.log(`done`) diff --git a/packages/console/core/script/onboard-zen-black.ts b/packages/console/core/script/black-onboard.ts similarity index 95% rename from packages/console/core/script/onboard-zen-black.ts rename to packages/console/core/script/black-onboard.ts index 3ee8809739d..77e5b779e35 100644 --- a/packages/console/core/script/onboard-zen-black.ts +++ b/packages/console/core/script/black-onboard.ts @@ -12,7 +12,7 @@ const email = process.argv[3] console.log(`Onboarding workspace ${workspaceID} for email ${email}`) if (!workspaceID || !email) { - console.error("Usage: bun onboard-zen-black.ts ") + console.error("Usage: bun foo.ts ") process.exit(1) } @@ -50,7 +50,7 @@ const existingSubscription = await Database.use((tx) => tx .select({ workspaceID: BillingTable.workspaceID }) .from(BillingTable) - .where(eq(BillingTable.subscriptionID, subscriptionID)) + .where(sql`JSON_EXTRACT(${BillingTable.subscription}, '$.id') = ${subscriptionID}`) .then((rows) => rows[0]), ) if (existingSubscription) { @@ -128,10 +128,15 @@ await Database.transaction(async (tx) => { .set({ customerID, subscriptionID, - subscriptionCouponID: couponID, paymentMethodID, paymentMethodLast4, paymentMethodType, + subscription: { + status: "subscribed", + coupon: couponID, + seats: 1, + plan: "200", + }, }) .where(eq(BillingTable.workspaceID, workspaceID)) diff --git a/packages/console/core/script/black-transfer.ts b/packages/console/core/script/black-transfer.ts index a7947fe7223..e962ba5d361 100644 --- a/packages/console/core/script/black-transfer.ts +++ b/packages/console/core/script/black-transfer.ts @@ -18,7 +18,7 @@ const fromBilling = await Database.use((tx) => .select({ customerID: BillingTable.customerID, subscriptionID: BillingTable.subscriptionID, - subscriptionCouponID: BillingTable.subscriptionCouponID, + subscription: BillingTable.subscription, paymentMethodID: BillingTable.paymentMethodID, paymentMethodType: BillingTable.paymentMethodType, paymentMethodLast4: BillingTable.paymentMethodLast4, @@ -119,7 +119,7 @@ await Database.transaction(async (tx) => { .set({ customerID: fromPrevPayment.customerID, subscriptionID: null, - subscriptionCouponID: null, + subscription: null, paymentMethodID: fromPrevPaymentMethods.data[0].id, paymentMethodLast4: fromPrevPaymentMethods.data[0].card?.last4 ?? null, paymentMethodType: fromPrevPaymentMethods.data[0].type, @@ -131,7 +131,7 @@ await Database.transaction(async (tx) => { .set({ customerID: fromBilling.customerID, subscriptionID: fromBilling.subscriptionID, - subscriptionCouponID: fromBilling.subscriptionCouponID, + subscription: fromBilling.subscription, paymentMethodID: fromBilling.paymentMethodID, paymentMethodLast4: fromBilling.paymentMethodLast4, paymentMethodType: fromBilling.paymentMethodType, diff --git a/packages/console/core/script/lookup-user.ts b/packages/console/core/script/lookup-user.ts index b3a104457ff..3dc5e7a968c 100644 --- a/packages/console/core/script/lookup-user.ts +++ b/packages/console/core/script/lookup-user.ts @@ -55,8 +55,9 @@ if (identifier.startsWith("wrk_")) { ), ) - // Get all payments for these workspaces - await Promise.all(users.map((u: { workspaceID: string }) => printWorkspace(u.workspaceID))) + for (const user of users) { + await printWorkspace(user.workspaceID) + } } async function printWorkspace(workspaceID: string) { @@ -114,11 +115,11 @@ async function printWorkspace(workspaceID: string) { balance: BillingTable.balance, customerID: BillingTable.customerID, reload: BillingTable.reload, + subscriptionID: BillingTable.subscriptionID, subscription: { - id: BillingTable.subscriptionID, - couponID: BillingTable.subscriptionCouponID, plan: BillingTable.subscriptionPlan, booked: BillingTable.timeSubscriptionBooked, + enrichment: BillingTable.subscription, }, }) .from(BillingTable) @@ -128,8 +129,13 @@ async function printWorkspace(workspaceID: string) { rows.map((row) => ({ ...row, balance: `$${(row.balance / 100000000).toFixed(2)}`, - subscription: row.subscription.id - ? `Subscribed ${row.subscription.couponID ? `(coupon: ${row.subscription.couponID}) ` : ""}` + subscription: row.subscriptionID + ? [ + `Black ${row.subscription.enrichment!.plan}`, + row.subscription.enrichment!.seats > 1 ? `X ${row.subscription.enrichment!.seats} seats` : "", + row.subscription.enrichment!.coupon ? `(coupon: ${row.subscription.enrichment!.coupon})` : "", + `(ref: ${row.subscriptionID})`, + ].join(" ") : row.subscription.booked ? `Waitlist ${row.subscription.plan} plan` : undefined, diff --git a/packages/console/core/src/schema/billing.sql.ts b/packages/console/core/src/schema/billing.sql.ts index f1300f8498b..56b2ef33b9f 100644 --- a/packages/console/core/src/schema/billing.sql.ts +++ b/packages/console/core/src/schema/billing.sql.ts @@ -21,16 +21,17 @@ export const BillingTable = mysqlTable( reloadError: varchar("reload_error", { length: 255 }), timeReloadError: utc("time_reload_error"), timeReloadLockedTill: utc("time_reload_locked_till"), + subscription: json("subscription").$type<{ + status: "subscribed" + coupon?: string + seats: number + plan: "20" | "100" | "200" + }>(), subscriptionID: varchar("subscription_id", { length: 28 }), - subscriptionCouponID: varchar("subscription_coupon_id", { length: 28 }), subscriptionPlan: mysqlEnum("subscription_plan", ["20", "100", "200"] as const), timeSubscriptionBooked: utc("time_subscription_booked"), }, - (table) => [ - ...workspaceIndexes(table), - uniqueIndex("global_customer_id").on(table.customerID), - uniqueIndex("global_subscription_id").on(table.subscriptionID), - ], + (table) => [...workspaceIndexes(table), uniqueIndex("global_customer_id").on(table.customerID)], ) export const SubscriptionTable = mysqlTable( From f96c4badd8d491291de7a5f9b06a948f353aa3d4 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 16 Jan 2026 17:48:26 -0500 Subject: [PATCH 007/104] wip: black --- .../migrations/0054_numerous_annihilus.sql | 1 + .../core/migrations/meta/0054_snapshot.json | 1309 +++++++++++++++++ .../core/migrations/meta/_journal.json | 7 + .../console/core/src/schema/billing.sql.ts | 6 +- 4 files changed, 1322 insertions(+), 1 deletion(-) create mode 100644 packages/console/core/migrations/0054_numerous_annihilus.sql create mode 100644 packages/console/core/migrations/meta/0054_snapshot.json diff --git a/packages/console/core/migrations/0054_numerous_annihilus.sql b/packages/console/core/migrations/0054_numerous_annihilus.sql new file mode 100644 index 00000000000..299847db64f --- /dev/null +++ b/packages/console/core/migrations/0054_numerous_annihilus.sql @@ -0,0 +1 @@ +ALTER TABLE `billing` DROP COLUMN `subscription_coupon_id`; \ No newline at end of file diff --git a/packages/console/core/migrations/meta/0054_snapshot.json b/packages/console/core/migrations/meta/0054_snapshot.json new file mode 100644 index 00000000000..429d4a2af1e --- /dev/null +++ b/packages/console/core/migrations/meta/0054_snapshot.json @@ -0,0 +1,1309 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "a0ade64b-b735-4a70-8d39-ebd84bc9e924", + "prevId": "32a0c40b-a269-4ad1-a5a0-52b1f18932aa", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "account_id_pk": { + "name": "account_id_pk", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "auth": { + "name": "auth", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "enum('email','github','google')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "provider": { + "name": "provider", + "columns": [ + "provider", + "subject" + ], + "isUnique": true + }, + "account_id": { + "name": "account_id", + "columns": [ + "account_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "auth_id_pk": { + "name": "auth_id_pk", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "benchmark": { + "name": "benchmark", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent": { + "name": "agent", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "time_created": { + "name": "time_created", + "columns": [ + "time_created" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "benchmark_id_pk": { + "name": "benchmark_id_pk", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "billing": { + "name": "billing", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_type": { + "name": "payment_method_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_last4": { + "name": "payment_method_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "balance": { + "name": "balance", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_usage": { + "name": "monthly_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_monthly_usage_updated": { + "name": "time_monthly_usage_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload": { + "name": "reload", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_trigger": { + "name": "reload_trigger", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_amount": { + "name": "reload_amount", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_error": { + "name": "reload_error", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_error": { + "name": "time_reload_error", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_locked_till": { + "name": "time_reload_locked_till", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription": { + "name": "subscription", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "varchar(28)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_plan": { + "name": "subscription_plan", + "type": "enum('20','100','200')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_subscription_booked": { + "name": "time_subscription_booked", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_customer_id": { + "name": "global_customer_id", + "columns": [ + "customer_id" + ], + "isUnique": true + }, + "global_subscription_id": { + "name": "global_subscription_id", + "columns": [ + "subscription_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "billing_workspace_id_id_pk": { + "name": "billing_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "payment": { + "name": "payment", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoice_id": { + "name": "invoice_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_id": { + "name": "payment_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_refunded": { + "name": "time_refunded", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enrichment": { + "name": "enrichment", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "payment_workspace_id_id_pk": { + "name": "payment_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "subscription": { + "name": "subscription", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rolling_usage": { + "name": "rolling_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fixed_usage": { + "name": "fixed_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_rolling_updated": { + "name": "time_rolling_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_fixed_updated": { + "name": "time_fixed_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspace_user_id": { + "name": "workspace_user_id", + "columns": [ + "workspace_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "subscription_workspace_id_id_pk": { + "name": "subscription_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "usage": { + "name": "usage", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_5m_tokens": { + "name": "cache_write_5m_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_1h_tokens": { + "name": "cache_write_1h_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost": { + "name": "cost", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_id": { + "name": "key_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enrichment": { + "name": "enrichment", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "usage_time_created": { + "name": "usage_time_created", + "columns": [ + "workspace_id", + "time_created" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_workspace_id_id_pk": { + "name": "usage_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ip_rate_limit": { + "name": "ip_rate_limit", + "columns": { + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "interval": { + "name": "interval", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ip_rate_limit_ip_interval_pk": { + "name": "ip_rate_limit_ip_interval_pk", + "columns": [ + "ip", + "interval" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ip": { + "name": "ip", + "columns": { + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage": { + "name": "usage", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ip_ip_pk": { + "name": "ip_ip_pk", + "columns": [ + "ip" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "key": { + "name": "key", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_used": { + "name": "time_used", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_key": { + "name": "global_key", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "key_workspace_id_id_pk": { + "name": "key_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "model": { + "name": "model", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "model_workspace_model": { + "name": "model_workspace_model", + "columns": [ + "workspace_id", + "model" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "model_workspace_id_id_pk": { + "name": "model_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "provider": { + "name": "provider", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_provider": { + "name": "workspace_provider", + "columns": [ + "workspace_id", + "provider" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "provider_workspace_id_id_pk": { + "name": "provider_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_seen": { + "name": "time_seen", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('admin','member')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_usage": { + "name": "monthly_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_monthly_usage_updated": { + "name": "time_monthly_usage_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_account_id": { + "name": "user_account_id", + "columns": [ + "workspace_id", + "account_id" + ], + "isUnique": true + }, + "user_email": { + "name": "user_email", + "columns": [ + "workspace_id", + "email" + ], + "isUnique": true + }, + "global_account_id": { + "name": "global_account_id", + "columns": [ + "account_id" + ], + "isUnique": false + }, + "global_email": { + "name": "global_email", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_workspace_id_id_pk": { + "name": "user_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "workspace": { + "name": "workspace", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "slug": { + "name": "slug", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "workspace_id": { + "name": "workspace_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/console/core/migrations/meta/_journal.json b/packages/console/core/migrations/meta/_journal.json index 89ffd242376..13bac50a306 100644 --- a/packages/console/core/migrations/meta/_journal.json +++ b/packages/console/core/migrations/meta/_journal.json @@ -379,6 +379,13 @@ "when": 1768599366758, "tag": "0053_gigantic_hardball", "breakpoints": true + }, + { + "idx": 54, + "version": "5", + "when": 1768603665356, + "tag": "0054_numerous_annihilus", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/console/core/src/schema/billing.sql.ts b/packages/console/core/src/schema/billing.sql.ts index 56b2ef33b9f..9f05919f240 100644 --- a/packages/console/core/src/schema/billing.sql.ts +++ b/packages/console/core/src/schema/billing.sql.ts @@ -31,7 +31,11 @@ export const BillingTable = mysqlTable( subscriptionPlan: mysqlEnum("subscription_plan", ["20", "100", "200"] as const), timeSubscriptionBooked: utc("time_subscription_booked"), }, - (table) => [...workspaceIndexes(table), uniqueIndex("global_customer_id").on(table.customerID)], + (table) => [ + ...workspaceIndexes(table), + uniqueIndex("global_customer_id").on(table.customerID), + uniqueIndex("global_subscription_id").on(table.subscriptionID), + ], ) export const SubscriptionTable = mysqlTable( From 632f20558a99ed0f5fccda2dc896da28cb86a987 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 16 Jan 2026 22:49:19 +0000 Subject: [PATCH 008/104] chore: generate --- .../core/migrations/meta/0053_snapshot.json | 134 ++++-------------- .../core/migrations/meta/0054_snapshot.json | 134 ++++-------------- .../core/migrations/meta/_journal.json | 2 +- 3 files changed, 61 insertions(+), 209 deletions(-) diff --git a/packages/console/core/migrations/meta/0053_snapshot.json b/packages/console/core/migrations/meta/0053_snapshot.json index fba46b14b6e..75a2cb7c929 100644 --- a/packages/console/core/migrations/meta/0053_snapshot.json +++ b/packages/console/core/migrations/meta/0053_snapshot.json @@ -43,9 +43,7 @@ "compositePrimaryKeys": { "account_id_pk": { "name": "account_id_pk", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -109,17 +107,12 @@ "indexes": { "provider": { "name": "provider", - "columns": [ - "provider", - "subject" - ], + "columns": ["provider", "subject"], "isUnique": true }, "account_id": { "name": "account_id", - "columns": [ - "account_id" - ], + "columns": ["account_id"], "isUnique": false } }, @@ -127,9 +120,7 @@ "compositePrimaryKeys": { "auth_id_pk": { "name": "auth_id_pk", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -193,9 +184,7 @@ "indexes": { "time_created": { "name": "time_created", - "columns": [ - "time_created" - ], + "columns": ["time_created"], "isUnique": false } }, @@ -203,9 +192,7 @@ "compositePrimaryKeys": { "benchmark_id_pk": { "name": "benchmark_id_pk", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -388,16 +375,12 @@ "indexes": { "global_customer_id": { "name": "global_customer_id", - "columns": [ - "customer_id" - ], + "columns": ["customer_id"], "isUnique": true }, "global_subscription_id": { "name": "global_subscription_id", - "columns": [ - "subscription_id" - ], + "columns": ["subscription_id"], "isUnique": true } }, @@ -405,10 +388,7 @@ "compositePrimaryKeys": { "billing_workspace_id_id_pk": { "name": "billing_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -502,10 +482,7 @@ "compositePrimaryKeys": { "payment_workspace_id_id_pk": { "name": "payment_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -590,10 +567,7 @@ "indexes": { "workspace_user_id": { "name": "workspace_user_id", - "columns": [ - "workspace_id", - "user_id" - ], + "columns": ["workspace_id", "user_id"], "isUnique": true } }, @@ -601,10 +575,7 @@ "compositePrimaryKeys": { "subscription_workspace_id_id_pk": { "name": "subscription_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -731,10 +702,7 @@ "indexes": { "usage_time_created": { "name": "usage_time_created", - "columns": [ - "workspace_id", - "time_created" - ], + "columns": ["workspace_id", "time_created"], "isUnique": false } }, @@ -742,10 +710,7 @@ "compositePrimaryKeys": { "usage_workspace_id_id_pk": { "name": "usage_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -781,10 +746,7 @@ "compositePrimaryKeys": { "ip_rate_limit_ip_interval_pk": { "name": "ip_rate_limit_ip_interval_pk", - "columns": [ - "ip", - "interval" - ] + "columns": ["ip", "interval"] } }, "uniqueConstraints": {}, @@ -836,9 +798,7 @@ "compositePrimaryKeys": { "ip_ip_pk": { "name": "ip_ip_pk", - "columns": [ - "ip" - ] + "columns": ["ip"] } }, "uniqueConstraints": {}, @@ -916,9 +876,7 @@ "indexes": { "global_key": { "name": "global_key", - "columns": [ - "key" - ], + "columns": ["key"], "isUnique": true } }, @@ -926,10 +884,7 @@ "compositePrimaryKeys": { "key_workspace_id_id_pk": { "name": "key_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -986,10 +941,7 @@ "indexes": { "model_workspace_model": { "name": "model_workspace_model", - "columns": [ - "workspace_id", - "model" - ], + "columns": ["workspace_id", "model"], "isUnique": true } }, @@ -997,10 +949,7 @@ "compositePrimaryKeys": { "model_workspace_id_id_pk": { "name": "model_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -1064,10 +1013,7 @@ "indexes": { "workspace_provider": { "name": "workspace_provider", - "columns": [ - "workspace_id", - "provider" - ], + "columns": ["workspace_id", "provider"], "isUnique": true } }, @@ -1075,10 +1021,7 @@ "compositePrimaryKeys": { "provider_workspace_id_id_pk": { "name": "provider_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -1191,32 +1134,22 @@ "indexes": { "user_account_id": { "name": "user_account_id", - "columns": [ - "workspace_id", - "account_id" - ], + "columns": ["workspace_id", "account_id"], "isUnique": true }, "user_email": { "name": "user_email", - "columns": [ - "workspace_id", - "email" - ], + "columns": ["workspace_id", "email"], "isUnique": true }, "global_account_id": { "name": "global_account_id", - "columns": [ - "account_id" - ], + "columns": ["account_id"], "isUnique": false }, "global_email": { "name": "global_email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": false } }, @@ -1224,10 +1157,7 @@ "compositePrimaryKeys": { "user_workspace_id_id_pk": { "name": "user_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -1284,9 +1214,7 @@ "indexes": { "slug": { "name": "slug", - "columns": [ - "slug" - ], + "columns": ["slug"], "isUnique": true } }, @@ -1294,9 +1222,7 @@ "compositePrimaryKeys": { "workspace_id": { "name": "workspace_id", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -1313,4 +1239,4 @@ "tables": {}, "indexes": {} } -} \ No newline at end of file +} diff --git a/packages/console/core/migrations/meta/0054_snapshot.json b/packages/console/core/migrations/meta/0054_snapshot.json index 429d4a2af1e..a1e3851d857 100644 --- a/packages/console/core/migrations/meta/0054_snapshot.json +++ b/packages/console/core/migrations/meta/0054_snapshot.json @@ -43,9 +43,7 @@ "compositePrimaryKeys": { "account_id_pk": { "name": "account_id_pk", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -109,17 +107,12 @@ "indexes": { "provider": { "name": "provider", - "columns": [ - "provider", - "subject" - ], + "columns": ["provider", "subject"], "isUnique": true }, "account_id": { "name": "account_id", - "columns": [ - "account_id" - ], + "columns": ["account_id"], "isUnique": false } }, @@ -127,9 +120,7 @@ "compositePrimaryKeys": { "auth_id_pk": { "name": "auth_id_pk", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -193,9 +184,7 @@ "indexes": { "time_created": { "name": "time_created", - "columns": [ - "time_created" - ], + "columns": ["time_created"], "isUnique": false } }, @@ -203,9 +192,7 @@ "compositePrimaryKeys": { "benchmark_id_pk": { "name": "benchmark_id_pk", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -381,16 +368,12 @@ "indexes": { "global_customer_id": { "name": "global_customer_id", - "columns": [ - "customer_id" - ], + "columns": ["customer_id"], "isUnique": true }, "global_subscription_id": { "name": "global_subscription_id", - "columns": [ - "subscription_id" - ], + "columns": ["subscription_id"], "isUnique": true } }, @@ -398,10 +381,7 @@ "compositePrimaryKeys": { "billing_workspace_id_id_pk": { "name": "billing_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -495,10 +475,7 @@ "compositePrimaryKeys": { "payment_workspace_id_id_pk": { "name": "payment_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -583,10 +560,7 @@ "indexes": { "workspace_user_id": { "name": "workspace_user_id", - "columns": [ - "workspace_id", - "user_id" - ], + "columns": ["workspace_id", "user_id"], "isUnique": true } }, @@ -594,10 +568,7 @@ "compositePrimaryKeys": { "subscription_workspace_id_id_pk": { "name": "subscription_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -724,10 +695,7 @@ "indexes": { "usage_time_created": { "name": "usage_time_created", - "columns": [ - "workspace_id", - "time_created" - ], + "columns": ["workspace_id", "time_created"], "isUnique": false } }, @@ -735,10 +703,7 @@ "compositePrimaryKeys": { "usage_workspace_id_id_pk": { "name": "usage_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -774,10 +739,7 @@ "compositePrimaryKeys": { "ip_rate_limit_ip_interval_pk": { "name": "ip_rate_limit_ip_interval_pk", - "columns": [ - "ip", - "interval" - ] + "columns": ["ip", "interval"] } }, "uniqueConstraints": {}, @@ -829,9 +791,7 @@ "compositePrimaryKeys": { "ip_ip_pk": { "name": "ip_ip_pk", - "columns": [ - "ip" - ] + "columns": ["ip"] } }, "uniqueConstraints": {}, @@ -909,9 +869,7 @@ "indexes": { "global_key": { "name": "global_key", - "columns": [ - "key" - ], + "columns": ["key"], "isUnique": true } }, @@ -919,10 +877,7 @@ "compositePrimaryKeys": { "key_workspace_id_id_pk": { "name": "key_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -979,10 +934,7 @@ "indexes": { "model_workspace_model": { "name": "model_workspace_model", - "columns": [ - "workspace_id", - "model" - ], + "columns": ["workspace_id", "model"], "isUnique": true } }, @@ -990,10 +942,7 @@ "compositePrimaryKeys": { "model_workspace_id_id_pk": { "name": "model_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -1057,10 +1006,7 @@ "indexes": { "workspace_provider": { "name": "workspace_provider", - "columns": [ - "workspace_id", - "provider" - ], + "columns": ["workspace_id", "provider"], "isUnique": true } }, @@ -1068,10 +1014,7 @@ "compositePrimaryKeys": { "provider_workspace_id_id_pk": { "name": "provider_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -1184,32 +1127,22 @@ "indexes": { "user_account_id": { "name": "user_account_id", - "columns": [ - "workspace_id", - "account_id" - ], + "columns": ["workspace_id", "account_id"], "isUnique": true }, "user_email": { "name": "user_email", - "columns": [ - "workspace_id", - "email" - ], + "columns": ["workspace_id", "email"], "isUnique": true }, "global_account_id": { "name": "global_account_id", - "columns": [ - "account_id" - ], + "columns": ["account_id"], "isUnique": false }, "global_email": { "name": "global_email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": false } }, @@ -1217,10 +1150,7 @@ "compositePrimaryKeys": { "user_workspace_id_id_pk": { "name": "user_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -1277,9 +1207,7 @@ "indexes": { "slug": { "name": "slug", - "columns": [ - "slug" - ], + "columns": ["slug"], "isUnique": true } }, @@ -1287,9 +1215,7 @@ "compositePrimaryKeys": { "workspace_id": { "name": "workspace_id", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -1306,4 +1232,4 @@ "tables": {}, "indexes": {} } -} \ No newline at end of file +} diff --git a/packages/console/core/migrations/meta/_journal.json b/packages/console/core/migrations/meta/_journal.json index 13bac50a306..dd0957e51ca 100644 --- a/packages/console/core/migrations/meta/_journal.json +++ b/packages/console/core/migrations/meta/_journal.json @@ -388,4 +388,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} From 3d095e7fe7087057467d29e78a579ff02d28da14 Mon Sep 17 00:00:00 2001 From: b3nw Date: Fri, 16 Jan 2026 16:57:17 -0600 Subject: [PATCH 009/104] fix: centralize OSC 52 clipboard support for SSH sessions (#8974) --- packages/opencode/src/cli/cmd/tui/app.tsx | 10 ---------- .../src/cli/cmd/tui/routes/session/index.tsx | 5 ----- packages/opencode/src/cli/cmd/tui/ui/dialog.tsx | 5 ----- .../opencode/src/cli/cmd/tui/util/clipboard.ts | 16 ++++++++++++++++ 4 files changed, 16 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 1fea3f4b305..2ec1fb703f9 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -200,11 +200,6 @@ function App() { renderer.console.onCopySelection = async (text: string) => { if (!text || text.length === 0) return - const base64 = Buffer.from(text).toString("base64") - const osc52 = `\x1b]52;c;${base64}\x07` - const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52 - // @ts-expect-error writeOut is not in type definitions - renderer.writeOut(finalOsc52) await Clipboard.copy(text) .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) .catch(toast.error) @@ -627,11 +622,6 @@ function App() { } const text = renderer.getSelection()?.getSelectedText() if (text && text.length > 0) { - const base64 = Buffer.from(text).toString("base64") - const osc52 = `\x1b]52;c;${base64}\x07` - const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52 - /* @ts-expect-error */ - renderer.writeOut(finalOsc52) await Clipboard.copy(text) .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) .catch(toast.error) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index d91363954a1..1d64a2ff156 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -697,11 +697,6 @@ export function Session() { return } - const base64 = Buffer.from(text).toString("base64") - const osc52 = `\x1b]52;c;${base64}\x07` - const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52 - /* @ts-expect-error */ - renderer.writeOut(finalOsc52) Clipboard.copy(text) .then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" })) .catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" })) diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index 4477d301562..57375ba09db 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -141,11 +141,6 @@ export function DialogProvider(props: ParentProps) { onMouseUp={async () => { const text = renderer.getSelection()?.getSelectedText() if (text && text.length > 0) { - const base64 = Buffer.from(text).toString("base64") - const osc52 = `\x1b]52;c;${base64}\x07` - const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52 - /* @ts-expect-error */ - renderer.writeOut(finalOsc52) await Clipboard.copy(text) .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) .catch(toast.error) diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 9c91cf3055a..2526f41714c 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -5,6 +5,21 @@ import { lazy } from "../../../../util/lazy.js" import { tmpdir } from "os" import path from "path" +/** + * Writes text to clipboard via OSC 52 escape sequence. + * This allows clipboard operations to work over SSH by having + * the terminal emulator handle the clipboard locally. + */ +function writeOsc52(text: string): void { + if (!process.stdout.isTTY) return + const base64 = Buffer.from(text).toString("base64") + const osc52 = `\x1b]52;c;${base64}\x07` + // tmux and screen require DCS passthrough wrapping + const passthrough = process.env["TMUX"] || process.env["STY"] + const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52 + process.stdout.write(sequence) +} + export namespace Clipboard { export interface Content { data: string @@ -123,6 +138,7 @@ export namespace Clipboard { }) export async function copy(text: string): Promise { + writeOsc52(text) await getCopyMethod()(text) } } From aca1eb6b5b1f74396a9856fa2c413a79a55ca69f Mon Sep 17 00:00:00 2001 From: Caleb Norton Date: Fri, 16 Jan 2026 16:59:07 -0600 Subject: [PATCH 010/104] fix(nix): add desktop application entry (#8972) --- nix/desktop.nix | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/nix/desktop.nix b/nix/desktop.nix index 4b659413aaa..9fb73b56316 100644 --- a/nix/desktop.nix +++ b/nix/desktop.nix @@ -15,6 +15,8 @@ cargo, rustc, makeBinaryWrapper, + copyDesktopItems, + makeDesktopItem, nodejs, jq, }: @@ -57,12 +59,28 @@ rustPlatform.buildRustPackage rec { pkg-config bun makeBinaryWrapper + copyDesktopItems cargo rustc nodejs jq ]; + # based on packages/desktop/src-tauri/release/appstream.metainfo.xml + desktopItems = lib.optionals stdenv.isLinux [ + (makeDesktopItem { + name = "ai.opencode.opencode"; + desktopName = "OpenCode"; + comment = "Open source AI coding agent"; + exec = "opencode-desktop"; + icon = "opencode"; + terminal = false; + type = "Application"; + categories = [ "Development" "IDE" ]; + startupWMClass = "opencode"; + }) + ]; + buildInputs = [ openssl ] @@ -121,6 +139,10 @@ rustPlatform.buildRustPackage rec { # It looks for them in the location specified in tauri.conf.json. postInstall = lib.optionalString stdenv.isLinux '' + # Install icon + mkdir -p $out/share/icons/hicolor/128x128/apps + cp ../../../packages/desktop/src-tauri/icons/prod/128x128.png $out/share/icons/hicolor/128x128/apps/opencode.png + # Wrap the binary to ensure it finds the libraries wrapProgram $out/bin/opencode-desktop \ --prefix LD_LIBRARY_PATH : ${ From 6e020ef9ef2b02576b26116a5c4bc7d7bc1c9c5b Mon Sep 17 00:00:00 2001 From: Caleb Norton Date: Fri, 16 Jan 2026 16:59:34 -0600 Subject: [PATCH 011/104] chore: cleanup nix (#8964) --- .gitignore | 1 + flake.lock | 6 +++--- flake.nix | 28 +++------------------------- 3 files changed, 7 insertions(+), 28 deletions(-) diff --git a/.gitignore b/.gitignore index 75fa054a5e4..78a77f81982 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ opencode.json a.out target .scripts +.direnv/ # Local dev files opencode-dev diff --git a/flake.lock b/flake.lock index 58bdca6bf6a..5ef276f0a08 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1768395095, - "narHash": "sha256-ZhuYJbwbZT32QA95tSkXd9zXHcdZj90EzHpEXBMabaw=", + "lastModified": 1768302833, + "narHash": "sha256-h5bRFy9bco+8QcK7rGoOiqMxMbmn21moTACofNLRMP4=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "13868c071cc73a5e9f610c47d7bb08e5da64fdd5", + "rev": "61db79b0c6b838d9894923920b612048e1201926", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 4219a7e8e10..32614640ad3 100644 --- a/flake.nix +++ b/flake.nix @@ -7,6 +7,7 @@ outputs = { + self, nixpkgs, ... }: @@ -107,33 +108,10 @@ }; in { - default = opencodePkg; + default = self.packages.${system}.opencode; + opencode = opencodePkg; desktop = desktopPkg; } ); - - apps = forEachSystem ( - system: - let - pkgs = pkgsFor system; - in - { - opencode-dev = { - type = "app"; - meta = { - description = "Nix devshell shell for OpenCode"; - runtimeInputs = [ pkgs.bun ]; - }; - program = "${ - pkgs.writeShellApplication { - name = "opencode-dev"; - text = '' - exec bun run dev "$@" - ''; - } - }/bin/opencode-dev"; - }; - } - ); }; } From c325aa11423f0fdadb80c334469d6ea1b86225e1 Mon Sep 17 00:00:00 2001 From: Daniel Polito Date: Fri, 16 Jan 2026 20:00:56 -0300 Subject: [PATCH 012/104] fix(desktop): Stream bash output + strip-asni (#8961) --- bun.lock | 1 + packages/ui/package.json | 1 + packages/ui/src/components/message-part.tsx | 3 ++- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index e71d700e1cb..9cda088153c 100644 --- a/bun.lock +++ b/bun.lock @@ -424,6 +424,7 @@ "shiki": "catalog:", "solid-js": "catalog:", "solid-list": "catalog:", + "strip-ansi": "7.1.2", "virtua": "catalog:", }, "devDependencies": { diff --git a/packages/ui/package.json b/packages/ui/package.json index ef6eec23ac8..0b490591c35 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -59,6 +59,7 @@ "shiki": "catalog:", "solid-js": "catalog:", "solid-list": "catalog:", + "strip-ansi": "7.1.2", "virtua": "catalog:" } } diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index e1a34a3241a..165f46f6c50 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -10,6 +10,7 @@ import { onCleanup, type JSX, } from "solid-js" +import stripAnsi from "strip-ansi" import { Dynamic } from "solid-js/web" import { AgentPart, @@ -926,7 +927,7 @@ ToolRegistry.register({ >
From 55224d64a2f39294af2382d14de1e469d95417c3 Mon Sep 17 00:00:00 2001 From: Github Action Date: Fri, 16 Jan 2026 23:01:30 +0000 Subject: [PATCH 013/104] Update flake.lock --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index 5ef276f0a08..2bfad510e7b 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1768302833, - "narHash": "sha256-h5bRFy9bco+8QcK7rGoOiqMxMbmn21moTACofNLRMP4=", + "lastModified": 1768456270, + "narHash": "sha256-NgaL2CCiUR6nsqUIY4yxkzz07iQUlUCany44CFv+OxY=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "61db79b0c6b838d9894923920b612048e1201926", + "rev": "f4606b01b39e09065df37905a2133905246db9ed", "type": "github" }, "original": { From 43a9c503899a36d3c1d89f6f4dbf15192f5ce9d6 Mon Sep 17 00:00:00 2001 From: Github Action Date: Fri, 16 Jan 2026 23:02:26 +0000 Subject: [PATCH 014/104] Update node_modules hash (x86_64-linux) --- nix/hashes.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/hashes.json b/nix/hashes.json index 255e44fe366..e9c7eed2a93 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,6 +1,6 @@ { "nodeModules": { - "x86_64-linux": "sha256-07XxcHLuToM4QfWVyaPLACxjPZ93ZM7gtpX2o08Lp18=", + "x86_64-linux": "sha256-4zchRpxzvHnPMcwumgL9yaX0deIXS5IGPp131eYsSvg=", "aarch64-linux": "sha256-0Im52dLeZ0ZtaPJr/U4m7+IRtOfziHNJI/Bu/V6cPho=", "aarch64-darwin": "sha256-U2UvE70nM0OI0VhIku8qnX+ptPbA+Q/y1BGXbFMcyt4=", "x86_64-darwin": "sha256-CpZFHBMPJSib2Vqs6oC8HQjQtviPUMa/qezHAe22N/A=" From 5c13b209aad88a2533e9181d8aa8b4b3b8408a16 Mon Sep 17 00:00:00 2001 From: Github Action Date: Fri, 16 Jan 2026 23:11:48 +0000 Subject: [PATCH 015/104] Update node_modules hash (x86_64-darwin) --- nix/hashes.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/hashes.json b/nix/hashes.json index e9c7eed2a93..9e03c17381b 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -3,6 +3,6 @@ "x86_64-linux": "sha256-4zchRpxzvHnPMcwumgL9yaX0deIXS5IGPp131eYsSvg=", "aarch64-linux": "sha256-0Im52dLeZ0ZtaPJr/U4m7+IRtOfziHNJI/Bu/V6cPho=", "aarch64-darwin": "sha256-U2UvE70nM0OI0VhIku8qnX+ptPbA+Q/y1BGXbFMcyt4=", - "x86_64-darwin": "sha256-CpZFHBMPJSib2Vqs6oC8HQjQtviPUMa/qezHAe22N/A=" + "x86_64-darwin": "sha256-WYaP44PWRGtoG1DIuUJUH4DvuaCuFhlJZ9fPzGsiIfE=" } } From 1aedb265dd6a9370f2bbf96a3db4a7e5166c22e5 Mon Sep 17 00:00:00 2001 From: Github Action Date: Fri, 16 Jan 2026 23:16:53 +0000 Subject: [PATCH 016/104] Update node_modules hash (aarch64-darwin) --- nix/hashes.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/hashes.json b/nix/hashes.json index 9e03c17381b..41fb0260278 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -2,7 +2,7 @@ "nodeModules": { "x86_64-linux": "sha256-4zchRpxzvHnPMcwumgL9yaX0deIXS5IGPp131eYsSvg=", "aarch64-linux": "sha256-0Im52dLeZ0ZtaPJr/U4m7+IRtOfziHNJI/Bu/V6cPho=", - "aarch64-darwin": "sha256-U2UvE70nM0OI0VhIku8qnX+ptPbA+Q/y1BGXbFMcyt4=", + "aarch64-darwin": "sha256-86d/G1q6xiHSSlm+/irXoKLb/yLQbV348uuSrBV70+Q=", "x86_64-darwin": "sha256-WYaP44PWRGtoG1DIuUJUH4DvuaCuFhlJZ9fPzGsiIfE=" } } From 08ef97b162d75b5824f6de3a8a4b6553a13853bf Mon Sep 17 00:00:00 2001 From: Amir Hasanbasic <43892661+hamir-suspect@users.noreply.github.com> Date: Sat, 17 Jan 2026 00:23:18 +0100 Subject: [PATCH 017/104] fix(opencode): add oauth polling safety margin in copilot device authentication (#8986) --- packages/opencode/src/plugin/copilot.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/plugin/copilot.ts b/packages/opencode/src/plugin/copilot.ts index 17ce9debc7d..776b70fe91a 100644 --- a/packages/opencode/src/plugin/copilot.ts +++ b/packages/opencode/src/plugin/copilot.ts @@ -3,6 +3,9 @@ import { Installation } from "@/installation" import { iife } from "@/util/iife" const CLIENT_ID = "Ov23li8tweQw6odWQebz" +// Add a small safety buffer when polling to avoid hitting the server +// slightly too early due to clock skew / timer drift. +const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 // 3 seconds function normalizeDomain(url: string) { return url.replace(/^https?:\/\//, "").replace(/\/$/, "") @@ -204,6 +207,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { const data = (await response.json()) as { access_token?: string error?: string + interval?: number } if (data.access_token) { @@ -230,13 +234,29 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { } if (data.error === "authorization_pending") { - await new Promise((resolve) => setTimeout(resolve, deviceData.interval * 1000)) + await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS) + continue + } + + if (data.error === "slow_down") { + // Based on the RFC spec, we must add 5 seconds to our current polling interval. + // (See https://www.rfc-editor.org/rfc/rfc8628#section-3.5) + let newInterval = (deviceData.interval + 5) * 1000 + + // GitHub OAuth API may return the new interval in seconds in the response. + // We should try to use that if provided with safety margin. + const serverInterval = data.interval + if (serverInterval && typeof serverInterval === 'number' && serverInterval > 0) { + newInterval = serverInterval * 1000 + } + + await Bun.sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS) continue } if (data.error) return { type: "failed" as const } - await new Promise((resolve) => setTimeout(resolve, deviceData.interval * 1000)) + await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS) continue } }, From 80020ade2e0063459859dbca262c72c10cba6f9b Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 16 Jan 2026 23:23:57 +0000 Subject: [PATCH 018/104] chore: generate --- packages/opencode/src/plugin/copilot.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/plugin/copilot.ts b/packages/opencode/src/plugin/copilot.ts index 776b70fe91a..932b3fd6aff 100644 --- a/packages/opencode/src/plugin/copilot.ts +++ b/packages/opencode/src/plugin/copilot.ts @@ -246,7 +246,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { // GitHub OAuth API may return the new interval in seconds in the response. // We should try to use that if provided with safety margin. const serverInterval = data.interval - if (serverInterval && typeof serverInterval === 'number' && serverInterval > 0) { + if (serverInterval && typeof serverInterval === "number" && serverInterval > 0) { newInterval = serverInterval * 1000 } From e8746ddb1d16b2fb49d80c5ad5ad874c0b82abcf Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 16 Jan 2026 18:48:16 -0500 Subject: [PATCH 019/104] zen: fix opus unicode characters closes #8967 --- .../console/app/src/routes/zen/util/provider/anthropic.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/provider/anthropic.ts b/packages/console/app/src/routes/zen/util/provider/anthropic.ts index 2546ad3ef15..a5f92a29acf 100644 --- a/packages/console/app/src/routes/zen/util/provider/anthropic.ts +++ b/packages/console/app/src/routes/zen/util/provider/anthropic.ts @@ -65,7 +65,6 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) => buffer = newBuffer const messages = [] - while (buffer.length >= 4) { // first 4 bytes are the total length (big-endian) const totalLength = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).getUint32(0, false) @@ -121,7 +120,9 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) => const parsedDataResult = JSON.parse(data) delete parsedDataResult.p - const bytes = atob(parsedDataResult.bytes) + const binary = atob(parsedDataResult.bytes) + const uint8 = Uint8Array.from(binary, (c) => c.charCodeAt(0)) + const bytes = decoder.decode(uint8) const eventName = JSON.parse(bytes).type messages.push([`event: ${eventName}`, "\n", `data: ${bytes}`, "\n\n"].join("")) } From cad415872e6a71a39fd46468abcc1737e76606b7 Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 16 Jan 2026 23:36:57 +0000 Subject: [PATCH 020/104] fix: recent sessions gutter --- packages/app/src/pages/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 5e291449f46..bc62c70232f 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1381,7 +1381,7 @@ export default function Layout(props: ParentProps) { return ( // @ts-ignore
- +
Recent sessions
From d645e8bbe18a3559d83de4d59c342e3a00ed8822 Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 16 Jan 2026 23:37:36 +0000 Subject: [PATCH 021/104] fix: (desktop) command palette width --- packages/ui/src/components/dialog.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/dialog.css b/packages/ui/src/components/dialog.css index a2f279f203a..5c7cdc233bf 100644 --- a/packages/ui/src/components/dialog.css +++ b/packages/ui/src/components/dialog.css @@ -24,7 +24,7 @@ [data-slot="dialog-container"] { position: relative; z-index: 50; - width: min(calc(100vw - 16px), 480px); + width: min(calc(100vw - 16px), 640px); height: min(calc(100vh - 16px), 512px); display: flex; flex-direction: column; From 1250486ddfef6f2db353af4977cd7f4654aaf72f Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 16 Jan 2026 23:43:41 +0000 Subject: [PATCH 022/104] feat: add Keybind component for displaying keyboard shortcuts --- packages/ui/src/components/keybind.css | 18 ++++++++++++++++++ packages/ui/src/components/keybind.tsx | 20 ++++++++++++++++++++ packages/ui/src/styles/index.css | 1 + 3 files changed, 39 insertions(+) create mode 100644 packages/ui/src/components/keybind.css create mode 100644 packages/ui/src/components/keybind.tsx diff --git a/packages/ui/src/components/keybind.css b/packages/ui/src/components/keybind.css new file mode 100644 index 00000000000..1a9e5dce43e --- /dev/null +++ b/packages/ui/src/components/keybind.css @@ -0,0 +1,18 @@ +[data-component="keybind"] { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + height: 20px; + padding: 0 8px; + border-radius: 2px; + background: var(--surface-base); + box-shadow: var(--shadow-xxs-border); + + /* text-12-medium */ + font-family: var(--font-family-sans); + font-size: 12px; + font-weight: var(--font-weight-medium); + line-height: 1; + color: var(--text-weak); +} diff --git a/packages/ui/src/components/keybind.tsx b/packages/ui/src/components/keybind.tsx new file mode 100644 index 00000000000..a0fa0483fd9 --- /dev/null +++ b/packages/ui/src/components/keybind.tsx @@ -0,0 +1,20 @@ +import type { ComponentProps, ParentProps } from "solid-js" + +export interface KeybindProps extends ParentProps { + class?: string + classList?: ComponentProps<"span">["classList"] +} + +export function Keybind(props: KeybindProps) { + return ( + + {props.children} + + ) +} diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 8ab4d6ca4d0..b4b0883aeb0 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -24,6 +24,7 @@ @import "../components/icon.css" layer(components); @import "../components/icon-button.css" layer(components); @import "../components/image-preview.css" layer(components); +@import "../components/keybind.css" layer(components); @import "../components/text-field.css" layer(components); @import "../components/inline-input.css" layer(components); @import "../components/list.css" layer(components); From b18fb16e9c649081319d25c22de94bdac8993706 Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 16 Jan 2026 23:43:45 +0000 Subject: [PATCH 023/104] refactor: use Keybind component in titlebar search button --- .../app/src/components/session/session-header.tsx | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 4b083771fb8..cf49734b7a5 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -16,6 +16,7 @@ import { Button } from "@opencode-ai/ui/button" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Popover } from "@opencode-ai/ui/popover" import { TextField } from "@opencode-ai/ui/text-field" +import { Keybind } from "@opencode-ai/ui/keybind" export function SessionHeader() { const globalSDK = useGlobalSDK() @@ -64,16 +65,7 @@ export function SessionHeader() {
- - {(keybind) => ( - - {keybind()} - - )} - + {(keybind) => {keybind()}} )} From 54e52896a4eeb40f1e61d80c20c3b01c7dd8ad86 Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 16 Jan 2026 23:46:57 +0000 Subject: [PATCH 024/104] refactor: use Keybind component in search modal list --- packages/app/src/components/dialog-select-file.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 3b80c2687f1..0f37e798963 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -1,6 +1,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { FileIcon } from "@opencode-ai/ui/file-icon" +import { Keybind } from "@opencode-ai/ui/keybind" import { List } from "@opencode-ai/ui/list" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { useParams } from "@solidjs/router" @@ -169,7 +170,7 @@ export function DialogSelectFile() {
- {formatKeybind(item.keybind ?? "")} + {formatKeybind(item.keybind ?? "")}
From 69215d456c545a6923c13cc183fcc8a027be72d7 Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 16 Jan 2026 23:49:34 +0000 Subject: [PATCH 025/104] fix: display arrow keys as symbols in keybind formatting --- packages/app/src/context/command.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index 3c640d8e9fa..a93ffc02454 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -104,7 +104,15 @@ export function formatKeybind(config: string): string { if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta") if (kb.key) { - const displayKey = kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1) + const arrows: Record = { + arrowup: "↑", + arrowdown: "↓", + arrowleft: "←", + arrowright: "→", + } + const displayKey = + arrows[kb.key.toLowerCase()] ?? + (kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1)) parts.push(displayKey) } From d1b93616f707a780208b8a5cad2984e8c2861926 Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 16 Jan 2026 23:51:45 +0000 Subject: [PATCH 026/104] fix: increase keybind border-radius in search modal --- packages/app/src/components/dialog-select-file.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 0f37e798963..a8d105f130b 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -170,7 +170,7 @@ export function DialogSelectFile() { - {formatKeybind(item.keybind ?? "")} + {formatKeybind(item.keybind ?? "")} From ab705dacfac1132e25ddf7ff358c4879ff6e9917 Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 16 Jan 2026 23:55:04 +0000 Subject: [PATCH 027/104] fix: add left padding to command items in search modal --- packages/app/src/components/dialog-select-file.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index a8d105f130b..3e177b08888 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -162,7 +162,7 @@ export function DialogSelectFile() { } > -
+
{item.title} From f8f1f46a4fb759466ef477bad10663e678900632 Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 16 Jan 2026 23:56:17 +0000 Subject: [PATCH 028/104] fix: adjust command item left padding in search modal --- packages/app/src/components/dialog-select-file.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 3e177b08888..0adb35fc848 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -162,7 +162,7 @@ export function DialogSelectFile() {
} > -
+
{item.title} From 6f78a71fa7d87faf8820b6f68ceb849c1a371967 Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 16 Jan 2026 23:59:42 +0000 Subject: [PATCH 029/104] feat: add hideIcon and class options to List search, customize search modal input --- packages/app/src/components/dialog-select-file.tsx | 2 +- packages/ui/src/components/list.tsx | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 0adb35fc848..1057e83a2da 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -136,7 +136,7 @@ export function DialogSelectFile() { return ( item.id} diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index dc83db53a47..fd618fbdd2c 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -8,6 +8,8 @@ import { TextField } from "./text-field" export interface ListSearchProps { placeholder?: string autofocus?: boolean + hideIcon?: boolean + class?: string } export interface ListProps extends FilteredListProps { @@ -146,9 +148,11 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) return (
-
+
- + + + Date: Sat, 17 Jan 2026 00:00:41 +0000 Subject: [PATCH 030/104] fix: reduce command item left padding in search modal --- packages/app/src/components/dialog-select-file.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 1057e83a2da..621b2fb2196 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -162,7 +162,7 @@ export function DialogSelectFile() {
} > -
+
{item.title} From d23c21023ababec01f2a80285f0f20fef1570d8e Mon Sep 17 00:00:00 2001 From: David Hill Date: Sat, 17 Jan 2026 00:28:44 +0000 Subject: [PATCH 031/104] fix: refine search modal styling and list component --- packages/app/src/components/dialog-select-file.tsx | 6 +++--- packages/ui/src/components/list.css | 11 +++++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 621b2fb2196..dee87b5588a 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -134,14 +134,14 @@ export function DialogSelectFile() { }) return ( - + item.id} filterKeys={["title", "description", "category"]} - groupBy={(item) => (grouped() ? item.category : "")} + groupBy={(item) => item.category} onMove={handleMove} onSelect={handleSelect} > diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index 651f5ef971e..03a77df0587 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -56,11 +56,14 @@ width: 20px; height: 20px; background-color: transparent; + opacity: 0.5; + transition: opacity 0.15s ease; &:hover:not(:disabled), &:focus:not(:disabled), &:active:not(:disabled) { background-color: transparent; + opacity: 0.7; } &:hover:not(:disabled) [data-slot="icon-svg"] { @@ -125,10 +128,14 @@ display: flex; flex-direction: column; + &:last-child { + padding-bottom: 12px; + } + [data-slot="list-header"] { display: flex; z-index: 10; - padding: 0 12px 8px 8px; + padding: 8px 12px 8px 12px; justify-content: space-between; align-items: center; align-self: stretch; @@ -136,7 +143,7 @@ position: sticky; top: 0; - color: var(--text-base); + color: var(--text-weak); /* text-14-medium */ font-family: var(--font-family-sans); From ef7ef6538e347c8d7052b641e3bcb8a95099f92e Mon Sep 17 00:00:00 2001 From: David Hill Date: Sat, 17 Jan 2026 00:30:27 +0000 Subject: [PATCH 032/104] fix: limit search modal max-height to 480px --- packages/app/src/components/dialog-select-file.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index dee87b5588a..432e531e192 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -134,7 +134,7 @@ export function DialogSelectFile() { }) return ( - + Date: Sat, 17 Jan 2026 00:35:32 +0000 Subject: [PATCH 033/104] fix: remove the secondary text from commands --- packages/app/src/pages/session.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index ca5e73a9be9..aad0b596cb9 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -419,7 +419,6 @@ export default function Page() { { id: "session.new", title: "New session", - description: "Create a new session", category: "Session", keybind: "mod+shift+s", slash: "new", @@ -437,7 +436,7 @@ export default function Page() { { id: "terminal.toggle", title: "Toggle terminal", - description: "Show or hide the terminal", + description: "", category: "View", keybind: "ctrl+`", slash: "terminal", @@ -446,7 +445,7 @@ export default function Page() { { id: "review.toggle", title: "Toggle review", - description: "Show or hide the review panel", + description: "", category: "View", keybind: "mod+shift+r", onSelect: () => view().reviewPanel.toggle(), From e0c6459faa3461585ed7df91e60de85b2fa242c9 Mon Sep 17 00:00:00 2001 From: David Hill Date: Sat, 17 Jan 2026 00:37:56 +0000 Subject: [PATCH 034/104] fix: remove smooth scroll behavior from list component --- packages/ui/src/components/list.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index fd618fbdd2c..5be7f95aeec 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -82,7 +82,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) return } const element = scrollRef()?.querySelector(`[data-key="${active()}"]`) - element?.scrollIntoView({ block: "center", behavior: "smooth" }) + element?.scrollIntoView({ block: "center" }) }) createEffect(() => { From 38847e13bb10d3cd951779836826b3b5272ec85b Mon Sep 17 00:00:00 2001 From: David Hill Date: Sat, 17 Jan 2026 00:41:47 +0000 Subject: [PATCH 035/104] fix: truncate long search queries in empty state --- packages/ui/src/components/list.css | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index 03a77df0587..ee9be422c97 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -94,7 +94,7 @@ [data-slot="list-empty-state"] { display: flex; - padding: 32px 0px; + padding: 32px 48px; flex-direction: column; justify-content: center; align-items: center; @@ -106,8 +106,9 @@ justify-content: center; align-items: center; gap: 2px; + max-width: 100%; color: var(--text-weak); - text-align: center; + white-space: nowrap; /* text-14-regular */ font-family: var(--font-family-sans); @@ -120,6 +121,8 @@ [data-slot="list-filter"] { color: var(--text-strong); + overflow: hidden; + text-overflow: ellipsis; } } From 759ce8fb8ef28c6c880d2867a36cc5c8fe44809b Mon Sep 17 00:00:00 2001 From: David Hill Date: Sat, 17 Jan 2026 01:06:53 +0000 Subject: [PATCH 036/104] fix: prevent text clipping on search button descenders --- packages/app/src/components/session/session-header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index cf49734b7a5..7070f0c9337 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -60,7 +60,7 @@ export function SessionHeader() { >
- + Search {name()}
From 06c543e93814d58ab3440fe4b8fbd1eb740ac694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Sat, 17 Jan 2026 03:26:08 +0100 Subject: [PATCH 037/104] fix(nix): resolve hash race condition in parallel matrix jobs (#8995) --- .github/workflows/nix-desktop.yml | 4 +- .github/workflows/update-nix-hashes.yml | 202 ++++++++++++++++++++---- nix/scripts/update-hashes.sh | 119 -------------- 3 files changed, 173 insertions(+), 152 deletions(-) delete mode 100755 nix/scripts/update-hashes.sh diff --git a/.github/workflows/nix-desktop.yml b/.github/workflows/nix-desktop.yml index 01cfaed78b4..3d7c4803133 100644 --- a/.github/workflows/nix-desktop.yml +++ b/.github/workflows/nix-desktop.yml @@ -9,6 +9,7 @@ on: - "nix/**" - "packages/app/**" - "packages/desktop/**" + - ".github/workflows/nix-desktop.yml" pull_request: paths: - "flake.nix" @@ -16,6 +17,7 @@ on: - "nix/**" - "packages/app/**" - "packages/desktop/**" + - ".github/workflows/nix-desktop.yml" workflow_dispatch: jobs: @@ -26,7 +28,7 @@ jobs: os: - blacksmith-4vcpu-ubuntu-2404 - blacksmith-4vcpu-ubuntu-2404-arm - - macos-15 + - macos-15-intel - macos-latest runs-on: ${{ matrix.os }} timeout-minutes: 60 diff --git a/.github/workflows/update-nix-hashes.yml b/.github/workflows/update-nix-hashes.yml index 19373f748f2..f80a57d25d8 100644 --- a/.github/workflows/update-nix-hashes.yml +++ b/.github/workflows/update-nix-hashes.yml @@ -10,11 +10,13 @@ on: - "bun.lock" - "package.json" - "packages/*/package.json" + - ".github/workflows/update-nix-hashes.yml" pull_request: paths: - "bun.lock" - "package.json" - "packages/*/package.json" + - ".github/workflows/update-nix-hashes.yml" jobs: update-flake: @@ -25,7 +27,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: token: ${{ secrets.GITHUB_TOKEN }} fetch-depth: 0 @@ -43,9 +45,9 @@ jobs: - name: Update ${{ env.TITLE }} run: | set -euo pipefail - echo "📦 Updating $TITLE..." + echo "Updating $TITLE..." nix flake update - echo "✅ $TITLE updated successfully" + echo "$TITLE updated successfully" - name: Commit ${{ env.TITLE }} changes env: @@ -53,7 +55,7 @@ jobs: run: | set -euo pipefail - echo "🔍 Checking for changes in tracked files..." + echo "Checking for changes in tracked files..." summarize() { local status="$1" @@ -71,29 +73,29 @@ jobs: FILES=(flake.lock flake.nix) STATUS="$(git status --short -- "${FILES[@]}" || true)" if [ -z "$STATUS" ]; then - echo "✅ No changes detected." + echo "No changes detected." summarize "no changes" exit 0 fi - echo "📝 Changes detected:" + echo "Changes detected:" echo "$STATUS" - echo "🔗 Staging files..." + echo "Staging files..." git add "${FILES[@]}" - echo "💾 Committing changes..." + echo "Committing changes..." git commit -m "Update $TITLE" - echo "✅ Changes committed" + echo "Changes committed" BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}" - echo "🌳 Pulling latest from branch: $BRANCH" - git pull --rebase origin "$BRANCH" - echo "🚀 Pushing changes to branch: $BRANCH" + echo "Pulling latest from branch: $BRANCH" + git pull --rebase --autostash origin "$BRANCH" + echo "Pushing changes to branch: $BRANCH" git push origin HEAD:"$BRANCH" - echo "✅ Changes pushed successfully" + echo "Changes pushed successfully" summarize "committed $(git rev-parse --short HEAD)" - update-node-modules-hash: + compute-node-modules-hash: needs: update-flake if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository strategy: @@ -111,11 +113,10 @@ jobs: runs-on: ${{ matrix.host }} env: SYSTEM: ${{ matrix.system }} - TITLE: node_modules hash (${{ matrix.system }}) steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: token: ${{ secrets.GITHUB_TOKEN }} fetch-depth: 0 @@ -125,6 +126,104 @@ jobs: - name: Setup Nix uses: nixbuild/nix-quick-install-action@v34 + - name: Compute node_modules hash + run: | + set -euo pipefail + + DUMMY="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + HASH_FILE="nix/hashes.json" + OUTPUT_FILE="hash-${SYSTEM}.txt" + + export NIX_KEEP_OUTPUTS=1 + export NIX_KEEP_DERIVATIONS=1 + + BUILD_LOG=$(mktemp) + TMP_JSON=$(mktemp) + trap 'rm -f "$BUILD_LOG" "$TMP_JSON"' EXIT + + if [ ! -f "$HASH_FILE" ]; then + mkdir -p "$(dirname "$HASH_FILE")" + echo '{"nodeModules":{}}' > "$HASH_FILE" + fi + + # Set dummy hash to force nix to rebuild and reveal correct hash + jq --arg system "$SYSTEM" --arg value "$DUMMY" \ + '.nodeModules = (.nodeModules // {}) | .nodeModules[$system] = $value' "$HASH_FILE" > "$TMP_JSON" + mv "$TMP_JSON" "$HASH_FILE" + + MODULES_ATTR=".#packages.${SYSTEM}.default.node_modules" + DRV_PATH="$(nix eval --raw "${MODULES_ATTR}.drvPath")" + + echo "Building node_modules for ${SYSTEM} to discover correct hash..." + echo "Attempting to realize derivation: ${DRV_PATH}" + REALISE_OUT=$(nix-store --realise "$DRV_PATH" --keep-failed 2>&1 | tee "$BUILD_LOG" || true) + + BUILD_PATH=$(echo "$REALISE_OUT" | grep "^/nix/store/" | head -n1 || true) + CORRECT_HASH="" + + if [ -n "$BUILD_PATH" ] && [ -d "$BUILD_PATH" ]; then + echo "Realized node_modules output: $BUILD_PATH" + CORRECT_HASH=$(nix hash path --sri "$BUILD_PATH" 2>/dev/null || true) + fi + + # Try to extract hash from build log + if [ -z "$CORRECT_HASH" ]; then + CORRECT_HASH="$(grep -E 'got:\s+sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | awk '{print $2}' | head -n1 || true)" + fi + + if [ -z "$CORRECT_HASH" ]; then + CORRECT_HASH="$(grep -A2 'hash mismatch' "$BUILD_LOG" | grep 'got:' | awk '{print $2}' | sed 's/sha256:/sha256-/' || true)" + fi + + # Try to hash from kept failed build directory + if [ -z "$CORRECT_HASH" ]; then + KEPT_DIR=$(grep -oE "build directory.*'[^']+'" "$BUILD_LOG" | grep -oE "'/[^']+'" | tr -d "'" | head -n1 || true) + if [ -z "$KEPT_DIR" ]; then + KEPT_DIR=$(grep -oE '/nix/var/nix/builds/[^ ]+' "$BUILD_LOG" | head -n1 || true) + fi + + if [ -n "$KEPT_DIR" ] && [ -d "$KEPT_DIR" ]; then + HASH_PATH="$KEPT_DIR" + [ -d "$KEPT_DIR/build" ] && HASH_PATH="$KEPT_DIR/build" + + if [ -d "$HASH_PATH/node_modules" ]; then + CORRECT_HASH=$(nix hash path --sri "$HASH_PATH" 2>/dev/null || true) + fi + fi + fi + + if [ -z "$CORRECT_HASH" ]; then + echo "Failed to determine correct node_modules hash for ${SYSTEM}." + cat "$BUILD_LOG" + exit 1 + fi + + echo "$CORRECT_HASH" > "$OUTPUT_FILE" + echo "Hash for ${SYSTEM}: $CORRECT_HASH" + + - name: Upload hash artifact + uses: actions/upload-artifact@v6 + with: + name: hash-${{ matrix.system }} + path: hash-${{ matrix.system }}.txt + retention-days: 1 + + commit-node-modules-hashes: + needs: compute-node-modules-hash + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: blacksmith-4vcpu-ubuntu-2404 + env: + TITLE: node_modules hashes + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + ref: ${{ github.head_ref || github.ref_name }} + repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + - name: Configure git run: | git config --global user.email "action@github.com" @@ -135,14 +234,57 @@ jobs: TARGET_BRANCH: ${{ github.head_ref || github.ref_name }} run: | BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}" - git pull origin "$BRANCH" + git pull --rebase --autostash origin "$BRANCH" - - name: Update ${{ env.TITLE }} + - name: Download all hash artifacts + uses: actions/download-artifact@v7 + with: + pattern: hash-* + merge-multiple: true + + - name: Merge hashes into hashes.json run: | set -euo pipefail - echo "🔄 Updating $TITLE..." - nix/scripts/update-hashes.sh - echo "✅ $TITLE updated successfully" + + HASH_FILE="nix/hashes.json" + + if [ ! -f "$HASH_FILE" ]; then + mkdir -p "$(dirname "$HASH_FILE")" + echo '{"nodeModules":{}}' > "$HASH_FILE" + fi + + echo "Merging hashes into ${HASH_FILE}..." + + shopt -s nullglob + files=(hash-*.txt) + if [ ${#files[@]} -eq 0 ]; then + echo "No hash files found, nothing to update" + exit 0 + fi + + EXPECTED_SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin" + for sys in $EXPECTED_SYSTEMS; do + if [ ! -f "hash-${sys}.txt" ]; then + echo "WARNING: Missing hash file for $sys" + fi + done + + for f in "${files[@]}"; do + system="${f#hash-}" + system="${system%.txt}" + hash=$(cat "$f") + if [ -z "$hash" ]; then + echo "WARNING: Empty hash for $system, skipping" + continue + fi + echo " $system: $hash" + jq --arg sys "$system" --arg h "$hash" \ + '.nodeModules = (.nodeModules // {}) | .nodeModules[$sys] = $h' "$HASH_FILE" > "${HASH_FILE}.tmp" + mv "${HASH_FILE}.tmp" "$HASH_FILE" + done + + echo "All hashes merged:" + cat "$HASH_FILE" - name: Commit ${{ env.TITLE }} changes env: @@ -150,7 +292,8 @@ jobs: run: | set -euo pipefail - echo "🔍 Checking for changes in tracked files..." + HASH_FILE="nix/hashes.json" + echo "Checking for changes..." summarize() { local status="$1" @@ -166,27 +309,22 @@ jobs: echo "" >> "$GITHUB_STEP_SUMMARY" } - FILES=(nix/hashes.json) + FILES=("$HASH_FILE") STATUS="$(git status --short -- "${FILES[@]}" || true)" if [ -z "$STATUS" ]; then - echo "✅ No changes detected." + echo "No changes detected." summarize "no changes" exit 0 fi - echo "📝 Changes detected:" + echo "Changes detected:" echo "$STATUS" - echo "🔗 Staging files..." git add "${FILES[@]}" - echo "💾 Committing changes..." git commit -m "Update $TITLE" - echo "✅ Changes committed" BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}" - echo "🌳 Pulling latest from branch: $BRANCH" - git pull --rebase origin "$BRANCH" - echo "🚀 Pushing changes to branch: $BRANCH" + git pull --rebase --autostash origin "$BRANCH" git push origin HEAD:"$BRANCH" - echo "✅ Changes pushed successfully" + echo "Changes pushed successfully" summarize "committed $(git rev-parse --short HEAD)" diff --git a/nix/scripts/update-hashes.sh b/nix/scripts/update-hashes.sh deleted file mode 100755 index 1e294fe4fb4..00000000000 --- a/nix/scripts/update-hashes.sh +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -DUMMY="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" -SYSTEM=${SYSTEM:-x86_64-linux} -DEFAULT_HASH_FILE=${MODULES_HASH_FILE:-nix/hashes.json} -HASH_FILE=${HASH_FILE:-$DEFAULT_HASH_FILE} - -if [ ! -f "$HASH_FILE" ]; then - cat >"$HASH_FILE" </dev/null 2>&1; then - if ! git ls-files --error-unmatch "$HASH_FILE" >/dev/null 2>&1; then - git add -N "$HASH_FILE" >/dev/null 2>&1 || true - fi -fi - -export DUMMY -export NIX_KEEP_OUTPUTS=1 -export NIX_KEEP_DERIVATIONS=1 - -cleanup() { - rm -f "${JSON_OUTPUT:-}" "${BUILD_LOG:-}" "${TMP_EXPR:-}" -} - -trap cleanup EXIT - -write_node_modules_hash() { - local value="$1" - local system="${2:-$SYSTEM}" - local temp - temp=$(mktemp) - - if jq -e '.nodeModules | type == "object"' "$HASH_FILE" >/dev/null 2>&1; then - jq --arg system "$system" --arg value "$value" '.nodeModules[$system] = $value' "$HASH_FILE" >"$temp" - else - jq --arg system "$system" --arg value "$value" '.nodeModules = {($system): $value}' "$HASH_FILE" >"$temp" - fi - - mv "$temp" "$HASH_FILE" -} - -TARGET="packages.${SYSTEM}.default" -MODULES_ATTR=".#packages.${SYSTEM}.default.node_modules" -CORRECT_HASH="" - -DRV_PATH="$(nix eval --raw "${MODULES_ATTR}.drvPath")" - -echo "Setting dummy node_modules outputHash for ${SYSTEM}..." -write_node_modules_hash "$DUMMY" - -BUILD_LOG=$(mktemp) -JSON_OUTPUT=$(mktemp) - -echo "Building node_modules for ${SYSTEM} to discover correct outputHash..." -echo "Attempting to realize derivation: ${DRV_PATH}" -REALISE_OUT=$(nix-store --realise "$DRV_PATH" --keep-failed 2>&1 | tee "$BUILD_LOG" || true) - -BUILD_PATH=$(echo "$REALISE_OUT" | grep "^/nix/store/" | head -n1 || true) -if [ -n "$BUILD_PATH" ] && [ -d "$BUILD_PATH" ]; then - echo "Realized node_modules output: $BUILD_PATH" - CORRECT_HASH=$(nix hash path --sri "$BUILD_PATH" 2>/dev/null || true) -fi - -if [ -z "$CORRECT_HASH" ]; then - CORRECT_HASH="$(grep -E 'got:\s+sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | awk '{print $2}' | head -n1 || true)" - - if [ -z "$CORRECT_HASH" ]; then - CORRECT_HASH="$(grep -A2 'hash mismatch' "$BUILD_LOG" | grep 'got:' | awk '{print $2}' | sed 's/sha256:/sha256-/' || true)" - fi - - if [ -z "$CORRECT_HASH" ]; then - echo "Searching for kept failed build directory..." - KEPT_DIR=$(grep -oE "build directory.*'[^']+'" "$BUILD_LOG" | grep -oE "'/[^']+'" | tr -d "'" | head -n1) - - if [ -z "$KEPT_DIR" ]; then - KEPT_DIR=$(grep -oE '/nix/var/nix/builds/[^ ]+' "$BUILD_LOG" | head -n1) - fi - - if [ -n "$KEPT_DIR" ] && [ -d "$KEPT_DIR" ]; then - echo "Found kept build directory: $KEPT_DIR" - if [ -d "$KEPT_DIR/build" ]; then - HASH_PATH="$KEPT_DIR/build" - else - HASH_PATH="$KEPT_DIR" - fi - - echo "Attempting to hash: $HASH_PATH" - ls -la "$HASH_PATH" || true - - if [ -d "$HASH_PATH/node_modules" ]; then - CORRECT_HASH=$(nix hash path --sri "$HASH_PATH" 2>/dev/null || true) - echo "Computed hash from kept build: $CORRECT_HASH" - fi - fi - fi -fi - -if [ -z "$CORRECT_HASH" ]; then - echo "Failed to determine correct node_modules hash for ${SYSTEM}." - echo "Build log:" - cat "$BUILD_LOG" - exit 1 -fi - -write_node_modules_hash "$CORRECT_HASH" - -jq -e --arg system "$SYSTEM" --arg hash "$CORRECT_HASH" '.nodeModules[$system] == $hash' "$HASH_FILE" >/dev/null - -echo "node_modules hash updated for ${SYSTEM}: $CORRECT_HASH" - -rm -f "$BUILD_LOG" -unset BUILD_LOG From e8357a87b0687da5eff66a3cead3a49ade7e396a Mon Sep 17 00:00:00 2001 From: Github Action Date: Sat, 17 Jan 2026 02:51:01 +0000 Subject: [PATCH 038/104] Update node_modules hashes --- nix/hashes.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/hashes.json b/nix/hashes.json index 41fb0260278..16a1c1f398b 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,7 +1,7 @@ { "nodeModules": { "x86_64-linux": "sha256-4zchRpxzvHnPMcwumgL9yaX0deIXS5IGPp131eYsSvg=", - "aarch64-linux": "sha256-0Im52dLeZ0ZtaPJr/U4m7+IRtOfziHNJI/Bu/V6cPho=", + "aarch64-linux": "sha256-3/BSRsl5pI0Iz3qAFZxIkOehFLZ2Ox9UsbdDHYzqlVg=", "aarch64-darwin": "sha256-86d/G1q6xiHSSlm+/irXoKLb/yLQbV348uuSrBV70+Q=", "x86_64-darwin": "sha256-WYaP44PWRGtoG1DIuUJUH4DvuaCuFhlJZ9fPzGsiIfE=" } From 7c3eeeb0fa2ce8516a776e7b375d333b8120b698 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 16 Jan 2026 20:09:36 -0800 Subject: [PATCH 039/104] fix: gpt id stuff fr fr this time :/ (#9006) --- packages/opencode/src/provider/provider.ts | 18 ++++ packages/opencode/src/provider/transform.ts | 102 ++++++++---------- .../opencode/test/provider/transform.test.ts | 46 ++++---- 3 files changed, 87 insertions(+), 79 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 9e2dd0ba0b5..bcb115edf41 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -999,6 +999,24 @@ export namespace Provider { opts.signal = combined } + // Strip openai itemId metadata following what codex does + // Codex uses #[serde(skip_serializing)] on id fields for all item types: + // Message, Reasoning, FunctionCall, LocalShellCall, CustomToolCall, WebSearchCall + // IDs are only re-attached for Azure with store=true + if (model.api.npm === "@ai-sdk/openai" && opts.body && opts.method === "POST") { + const body = JSON.parse(opts.body as string) + const isAzure = model.providerID.includes("azure") + const keepIds = isAzure && body.store === true + if (!keepIds && Array.isArray(body.input)) { + for (const item of body.input) { + if ("id" in item) { + delete item.id + } + } + opts.body = JSON.stringify(body) + } + } + return fetchFn(input, { ...opts, // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682 diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 023b00dd304..71295ba2b67 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -16,34 +16,33 @@ function mimeToModality(mime: string): Modality | undefined { } export namespace ProviderTransform { + // Maps npm package to the key the AI SDK expects for providerOptions + function sdkKey(npm: string): string | undefined { + switch (npm) { + case "@ai-sdk/github-copilot": + case "@ai-sdk/openai": + case "@ai-sdk/azure": + return "openai" + case "@ai-sdk/amazon-bedrock": + return "bedrock" + case "@ai-sdk/anthropic": + return "anthropic" + case "@ai-sdk/google-vertex": + case "@ai-sdk/google": + return "google" + case "@ai-sdk/gateway": + return "gateway" + case "@openrouter/ai-sdk-provider": + return "openrouter" + } + return undefined + } + function normalizeMessages( msgs: ModelMessage[], model: Provider.Model, options: Record, ): ModelMessage[] { - // Strip openai itemId metadata following what codex does - if (model.api.npm === "@ai-sdk/openai" || options.store === false) { - msgs = msgs.map((msg) => { - if (msg.providerOptions) { - for (const options of Object.values(msg.providerOptions)) { - delete options["itemId"] - } - } - if (!Array.isArray(msg.content)) { - return msg - } - const content = msg.content.map((part) => { - if (part.providerOptions) { - for (const options of Object.values(part.providerOptions)) { - delete options["itemId"] - } - } - return part - }) - return { ...msg, content } as typeof msg - }) - } - // Anthropic rejects messages with empty content - filter out empty string messages // and remove empty text/reasoning parts from array content if (model.api.npm === "@ai-sdk/anthropic") { @@ -257,6 +256,28 @@ export namespace ProviderTransform { msgs = applyCaching(msgs, model.providerID) } + // Remap providerOptions keys from stored providerID to expected SDK key + const key = sdkKey(model.api.npm) + if (key && key !== model.providerID) { + const remap = (opts: Record | undefined) => { + if (!opts) return opts + if (!(model.providerID in opts)) return opts + const result = { ...opts } + result[key] = result[model.providerID] + delete result[model.providerID] + return result + } + + msgs = msgs.map((msg) => { + if (!Array.isArray(msg.content)) return { ...msg, providerOptions: remap(msg.providerOptions) } + return { + ...msg, + providerOptions: remap(msg.providerOptions), + content: msg.content.map((part) => ({ ...part, providerOptions: remap(part.providerOptions) })), + } as typeof msg + }) + } + return msgs } @@ -574,39 +595,8 @@ export namespace ProviderTransform { } export function providerOptions(model: Provider.Model, options: { [x: string]: any }) { - switch (model.api.npm) { - case "@ai-sdk/github-copilot": - case "@ai-sdk/openai": - case "@ai-sdk/azure": - return { - ["openai" as string]: options, - } - case "@ai-sdk/amazon-bedrock": - return { - ["bedrock" as string]: options, - } - case "@ai-sdk/anthropic": - return { - ["anthropic" as string]: options, - } - case "@ai-sdk/google-vertex": - case "@ai-sdk/google": - return { - ["google" as string]: options, - } - case "@ai-sdk/gateway": - return { - ["gateway" as string]: options, - } - case "@openrouter/ai-sdk-provider": - return { - ["openrouter" as string]: options, - } - default: - return { - [model.providerID]: options, - } - } + const key = sdkKey(model.api.npm) ?? model.providerID + return { [key]: options } } export function maxOutputTokens( diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 33047b5bcb4..0f203b93022 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -649,7 +649,7 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( headers: {}, } as any - test("strips itemId and reasoningEncryptedContent when store=false", () => { + test("preserves itemId and reasoningEncryptedContent when store=false", () => { const msgs = [ { role: "assistant", @@ -680,11 +680,11 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( const result = ProviderTransform.message(msgs, openaiModel, { store: false }) as any[] expect(result).toHaveLength(1) - expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() - expect(result[0].content[1].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("rs_123") + expect(result[0].content[1].providerOptions?.openai?.itemId).toBe("msg_456") }) - test("strips itemId and reasoningEncryptedContent when store=false even when not openai", () => { + test("preserves itemId and reasoningEncryptedContent when store=false even when not openai", () => { const zenModel = { ...openaiModel, providerID: "zen", @@ -719,11 +719,11 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( const result = ProviderTransform.message(msgs, zenModel, { store: false }) as any[] expect(result).toHaveLength(1) - expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() - expect(result[0].content[1].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("rs_123") + expect(result[0].content[1].providerOptions?.openai?.itemId).toBe("msg_456") }) - test("preserves other openai options when stripping itemId", () => { + test("preserves other openai options including itemId", () => { const msgs = [ { role: "assistant", @@ -744,11 +744,11 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( const result = ProviderTransform.message(msgs, openaiModel, { store: false }) as any[] - expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123") expect(result[0].content[0].providerOptions?.openai?.otherOption).toBe("value") }) - test("strips metadata for openai package even when store is true", () => { + test("preserves metadata for openai package when store is true", () => { const msgs = [ { role: "assistant", @@ -766,13 +766,13 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( }, ] as any[] - // openai package always strips itemId regardless of store value + // openai package preserves itemId regardless of store value const result = ProviderTransform.message(msgs, openaiModel, { store: true }) as any[] - expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123") }) - test("strips metadata for non-openai packages when store is false", () => { + test("preserves metadata for non-openai packages when store is false", () => { const anthropicModel = { ...openaiModel, providerID: "anthropic", @@ -799,13 +799,13 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( }, ] as any[] - // store=false triggers stripping even for non-openai packages + // store=false preserves metadata for non-openai packages const result = ProviderTransform.message(msgs, anthropicModel, { store: false }) as any[] - expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123") }) - test("strips metadata using providerID key when store is false", () => { + test("preserves metadata using providerID key when store is false", () => { const opencodeModel = { ...openaiModel, providerID: "opencode", @@ -835,11 +835,11 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( const result = ProviderTransform.message(msgs, opencodeModel, { store: false }) as any[] - expect(result[0].content[0].providerOptions?.opencode?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.opencode?.itemId).toBe("msg_123") expect(result[0].content[0].providerOptions?.opencode?.otherOption).toBe("value") }) - test("strips itemId across all providerOptions keys", () => { + test("preserves itemId across all providerOptions keys", () => { const opencodeModel = { ...openaiModel, providerID: "opencode", @@ -873,12 +873,12 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( const result = ProviderTransform.message(msgs, opencodeModel, { store: false }) as any[] - expect(result[0].providerOptions?.openai?.itemId).toBeUndefined() - expect(result[0].providerOptions?.opencode?.itemId).toBeUndefined() - expect(result[0].providerOptions?.extra?.itemId).toBeUndefined() - expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() - expect(result[0].content[0].providerOptions?.opencode?.itemId).toBeUndefined() - expect(result[0].content[0].providerOptions?.extra?.itemId).toBeUndefined() + expect(result[0].providerOptions?.openai?.itemId).toBe("msg_root") + expect(result[0].providerOptions?.opencode?.itemId).toBe("msg_opencode") + expect(result[0].providerOptions?.extra?.itemId).toBe("msg_extra") + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_openai_part") + expect(result[0].content[0].providerOptions?.opencode?.itemId).toBe("msg_opencode_part") + expect(result[0].content[0].providerOptions?.extra?.itemId).toBe("msg_extra_part") }) test("does not strip metadata for non-openai packages when store is not false", () => { From db78a59f03c2b8acbda506b9f3f5d6c92d3755de Mon Sep 17 00:00:00 2001 From: ben Date: Fri, 16 Jan 2026 20:15:59 -0800 Subject: [PATCH 040/104] docs: Add OpenWork to ecosystem (#8741) --- packages/web/src/content/docs/ecosystem.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index 44a73de69e9..19e84b3ba66 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -57,6 +57,7 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw | [ai-sdk-provider-opencode-sdk](https://github.com/ben-vargas/ai-sdk-provider-opencode-sdk) | Vercel AI SDK provider for using OpenCode via @opencode-ai/sdk | | [OpenChamber](https://github.com/btriapitsyn/openchamber) | Web / Desktop App and VS Code Extension for OpenCode | | [OpenCode-Obsidian](https://github.com/mtymek/opencode-obsidian) | Obsidian plugin that embedds OpenCode in Obsidian's UI | +| [OpenWork](https://github.com/different-ai/openwork) | An open-source alternative to Claude Cowork, powered by OpenCode| --- From 5d613a038d1e562197f1a22d44d4a5c51477b1cd Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 17 Jan 2026 04:16:35 +0000 Subject: [PATCH 041/104] chore: generate --- packages/web/src/content/docs/ecosystem.mdx | 22 ++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index 19e84b3ba66..ce3e3deb86c 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -47,17 +47,17 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw ## Projects -| Name | Description | -| ------------------------------------------------------------------------------------------ | --------------------------------------------------------------- | -| [kimaki](https://github.com/remorses/kimaki) | Discord bot to control OpenCode sessions, built on the SDK | -| [opencode.nvim](https://github.com/NickvanDyke/opencode.nvim) | Neovim plugin for editor-aware prompts, built on the API | -| [portal](https://github.com/hosenur/portal) | Mobile-first web UI for OpenCode over Tailscale/VPN | -| [opencode plugin template](https://github.com/zenobi-us/opencode-plugin-template/) | Template for building OpenCode plugins | -| [opencode.nvim](https://github.com/sudo-tee/opencode.nvim) | Neovim frontend for opencode - a terminal-based AI coding agent | -| [ai-sdk-provider-opencode-sdk](https://github.com/ben-vargas/ai-sdk-provider-opencode-sdk) | Vercel AI SDK provider for using OpenCode via @opencode-ai/sdk | -| [OpenChamber](https://github.com/btriapitsyn/openchamber) | Web / Desktop App and VS Code Extension for OpenCode | -| [OpenCode-Obsidian](https://github.com/mtymek/opencode-obsidian) | Obsidian plugin that embedds OpenCode in Obsidian's UI | -| [OpenWork](https://github.com/different-ai/openwork) | An open-source alternative to Claude Cowork, powered by OpenCode| +| Name | Description | +| ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- | +| [kimaki](https://github.com/remorses/kimaki) | Discord bot to control OpenCode sessions, built on the SDK | +| [opencode.nvim](https://github.com/NickvanDyke/opencode.nvim) | Neovim plugin for editor-aware prompts, built on the API | +| [portal](https://github.com/hosenur/portal) | Mobile-first web UI for OpenCode over Tailscale/VPN | +| [opencode plugin template](https://github.com/zenobi-us/opencode-plugin-template/) | Template for building OpenCode plugins | +| [opencode.nvim](https://github.com/sudo-tee/opencode.nvim) | Neovim frontend for opencode - a terminal-based AI coding agent | +| [ai-sdk-provider-opencode-sdk](https://github.com/ben-vargas/ai-sdk-provider-opencode-sdk) | Vercel AI SDK provider for using OpenCode via @opencode-ai/sdk | +| [OpenChamber](https://github.com/btriapitsyn/openchamber) | Web / Desktop App and VS Code Extension for OpenCode | +| [OpenCode-Obsidian](https://github.com/mtymek/opencode-obsidian) | Obsidian plugin that embedds OpenCode in Obsidian's UI | +| [OpenWork](https://github.com/different-ai/openwork) | An open-source alternative to Claude Cowork, powered by OpenCode | --- From 33290c54cdd68081236ede205133d255ed639455 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 16 Jan 2026 21:00:25 -0600 Subject: [PATCH 042/104] Revert "feat(mcp): add OAuth redirect URI configuration for MCP servers (#7379)" This reverts commit 40b275d7e6759d2209bbaec7d4d13f729198bda3. --- packages/opencode/src/cli/cmd/mcp.ts | 6 -- packages/opencode/src/config/config.ts | 4 - packages/opencode/src/mcp/index.ts | 27 +++---- packages/opencode/src/mcp/oauth-callback.ts | 34 +++------ packages/opencode/src/mcp/oauth-provider.ts | 24 ------ .../opencode/test/mcp/oauth-callback.test.ts | 75 ------------------- 6 files changed, 19 insertions(+), 151 deletions(-) delete mode 100644 packages/opencode/test/mcp/oauth-callback.test.ts diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index fedad92856f..95719215e32 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -6,7 +6,6 @@ import * as prompts from "@clack/prompts" import { UI } from "../ui" import { MCP } from "../../mcp" import { McpAuth } from "../../mcp/auth" -import { McpOAuthCallback } from "../../mcp/oauth-callback" import { McpOAuthProvider } from "../../mcp/oauth-provider" import { Config } from "../../config/config" import { Instance } from "../../project/instance" @@ -683,10 +682,6 @@ export const McpDebugCommand = cmd({ // Try to discover OAuth metadata const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined - - // Start callback server - await McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri) - const authProvider = new McpOAuthProvider( serverName, serverConfig.url, @@ -694,7 +689,6 @@ export const McpDebugCommand = cmd({ clientId: oauthConfig?.clientId, clientSecret: oauthConfig?.clientSecret, scope: oauthConfig?.scope, - redirectUri: oauthConfig?.redirectUri, }, { onRedirect: async () => {}, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 355b3ba0017..1574c644d32 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -435,10 +435,6 @@ export namespace Config { .describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."), clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"), scope: z.string().optional().describe("OAuth scopes to request during authorization"), - redirectUri: z - .string() - .optional() - .describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."), }) .strict() .meta({ diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 7b9a8c2076a..66843aedc11 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -308,8 +308,6 @@ export namespace MCP { let authProvider: McpOAuthProvider | undefined if (!oauthDisabled) { - await McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri) - authProvider = new McpOAuthProvider( key, mcp.url, @@ -317,7 +315,6 @@ export namespace MCP { clientId: oauthConfig?.clientId, clientSecret: oauthConfig?.clientSecret, scope: oauthConfig?.scope, - redirectUri: oauthConfig?.redirectUri, }, { onRedirect: async (url) => { @@ -347,7 +344,6 @@ export namespace MCP { let lastError: Error | undefined const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT - for (const { name, transport } of transports) { try { const client = new Client({ @@ -574,8 +570,7 @@ export namespace MCP { for (const [clientName, client] of Object.entries(clientsSnapshot)) { // Only include tools from connected MCPs (skip disabled ones) - const clientStatus = s.status[clientName]?.status - if (clientStatus !== "connected") { + if (s.status[clientName]?.status !== "connected") { continue } @@ -725,10 +720,8 @@ export namespace MCP { throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`) } - // OAuth config is optional - if not provided, we'll use auto-discovery - const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined - - await McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri) + // Start the callback server + await McpOAuthCallback.ensureRunning() // Generate and store a cryptographically secure state parameter BEFORE creating the provider // The SDK will call provider.state() to read this value @@ -738,6 +731,8 @@ export namespace MCP { await McpAuth.updateOAuthState(mcpName, oauthState) // Create a new auth provider for this flow + // OAuth config is optional - if not provided, we'll use auto-discovery + const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined let capturedUrl: URL | undefined const authProvider = new McpOAuthProvider( mcpName, @@ -746,7 +741,6 @@ export namespace MCP { clientId: oauthConfig?.clientId, clientSecret: oauthConfig?.clientSecret, scope: oauthConfig?.scope, - redirectUri: oauthConfig?.redirectUri, }, { onRedirect: async (url) => { @@ -775,7 +769,6 @@ export namespace MCP { pendingOAuthTransports.set(mcpName, transport) return { authorizationUrl: capturedUrl.toString() } } - throw error } } @@ -785,9 +778,9 @@ export namespace MCP { * Opens the browser and waits for callback. */ export async function authenticate(mcpName: string): Promise { - const result = await startAuth(mcpName) + const { authorizationUrl } = await startAuth(mcpName) - if (!result.authorizationUrl) { + if (!authorizationUrl) { // Already authenticated const s = await state() return s.status[mcpName] ?? { status: "connected" } @@ -801,9 +794,9 @@ export namespace MCP { // The SDK has already added the state parameter to the authorization URL // We just need to open the browser - log.info("opening browser for oauth", { mcpName, url: result.authorizationUrl, state: oauthState }) + log.info("opening browser for oauth", { mcpName, url: authorizationUrl, state: oauthState }) try { - const subprocess = await open(result.authorizationUrl) + const subprocess = await open(authorizationUrl) // The open package spawns a detached process and returns immediately. // We need to listen for errors which fire asynchronously: // - "error" event: command not found (ENOENT) @@ -826,7 +819,7 @@ export namespace MCP { // Browser opening failed (e.g., in remote/headless sessions like SSH, devcontainers) // Emit event so CLI can display the URL for manual opening log.warn("failed to open browser, user must open URL manually", { mcpName, error }) - Bus.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }) + Bus.publish(BrowserOpenFailed, { mcpName, url: authorizationUrl }) } // Wait for callback using the OAuth state parameter diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts index a690ab5e336..bb3b56f2e95 100644 --- a/packages/opencode/src/mcp/oauth-callback.ts +++ b/packages/opencode/src/mcp/oauth-callback.ts @@ -1,12 +1,8 @@ import { Log } from "../util/log" -import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider" +import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider" const log = Log.create({ service: "mcp.oauth-callback" }) -// Current callback server configuration (may differ from defaults if custom redirectUri is used) -let currentPort = OAUTH_CALLBACK_PORT -let currentPath = OAUTH_CALLBACK_PATH - const HTML_SUCCESS = ` @@ -60,33 +56,21 @@ export namespace McpOAuthCallback { const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes - export async function ensureRunning(redirectUri?: string): Promise { - // Parse the redirect URI to get port and path (uses defaults if not provided) - const { port, path } = parseRedirectUri(redirectUri) - - // If server is running on a different port/path, stop it first - if (server && (currentPort !== port || currentPath !== path)) { - log.info("stopping oauth callback server to reconfigure", { oldPort: currentPort, newPort: port }) - await stop() - } - + export async function ensureRunning(): Promise { if (server) return - const running = await isPortInUse(port) + const running = await isPortInUse() if (running) { - log.info("oauth callback server already running on another instance", { port }) + log.info("oauth callback server already running on another instance", { port: OAUTH_CALLBACK_PORT }) return } - currentPort = port - currentPath = path - server = Bun.serve({ - port: currentPort, + port: OAUTH_CALLBACK_PORT, fetch(req) { const url = new URL(req.url) - if (url.pathname !== currentPath) { + if (url.pathname !== OAUTH_CALLBACK_PATH) { return new Response("Not found", { status: 404 }) } @@ -149,7 +133,7 @@ export namespace McpOAuthCallback { }, }) - log.info("oauth callback server started", { port: currentPort, path: currentPath }) + log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT }) } export function waitForCallback(oauthState: string): Promise { @@ -174,11 +158,11 @@ export namespace McpOAuthCallback { } } - export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise { + export async function isPortInUse(): Promise { return new Promise((resolve) => { Bun.connect({ hostname: "127.0.0.1", - port, + port: OAUTH_CALLBACK_PORT, socket: { open(socket) { socket.end() diff --git a/packages/opencode/src/mcp/oauth-provider.ts b/packages/opencode/src/mcp/oauth-provider.ts index 82bad60da33..35ead25e8be 100644 --- a/packages/opencode/src/mcp/oauth-provider.ts +++ b/packages/opencode/src/mcp/oauth-provider.ts @@ -17,7 +17,6 @@ export interface McpOAuthConfig { clientId?: string clientSecret?: string scope?: string - redirectUri?: string } export interface McpOAuthCallbacks { @@ -33,10 +32,6 @@ export class McpOAuthProvider implements OAuthClientProvider { ) {} get redirectUrl(): string { - // Use configured redirectUri if provided, otherwise use OpenCode defaults - if (this.config.redirectUri) { - return this.config.redirectUri - } return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}` } @@ -157,22 +152,3 @@ export class McpOAuthProvider implements OAuthClientProvider { } export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } - -/** - * Parse a redirect URI to extract port and path for the callback server. - * Returns defaults if the URI can't be parsed. - */ -export function parseRedirectUri(redirectUri?: string): { port: number; path: string } { - if (!redirectUri) { - return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH } - } - - try { - const url = new URL(redirectUri) - const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80 - const path = url.pathname || OAUTH_CALLBACK_PATH - return { port, path } - } catch { - return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH } - } -} diff --git a/packages/opencode/test/mcp/oauth-callback.test.ts b/packages/opencode/test/mcp/oauth-callback.test.ts deleted file mode 100644 index aa23f4dfb5d..00000000000 --- a/packages/opencode/test/mcp/oauth-callback.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { test, expect, describe, afterEach } from "bun:test" -import { McpOAuthCallback } from "../../src/mcp/oauth-callback" -import { parseRedirectUri } from "../../src/mcp/oauth-provider" - -describe("McpOAuthCallback.ensureRunning", () => { - afterEach(async () => { - await McpOAuthCallback.stop() - }) - - test("starts server with default config when no redirectUri provided", async () => { - await McpOAuthCallback.ensureRunning() - expect(McpOAuthCallback.isRunning()).toBe(true) - }) - - test("starts server with custom redirectUri", async () => { - await McpOAuthCallback.ensureRunning("http://127.0.0.1:18000/custom/callback") - expect(McpOAuthCallback.isRunning()).toBe(true) - }) - - test("is idempotent when called with same redirectUri", async () => { - await McpOAuthCallback.ensureRunning("http://127.0.0.1:18001/callback") - await McpOAuthCallback.ensureRunning("http://127.0.0.1:18001/callback") - expect(McpOAuthCallback.isRunning()).toBe(true) - }) - - test("restarts server when redirectUri changes", async () => { - await McpOAuthCallback.ensureRunning("http://127.0.0.1:18002/path1") - expect(McpOAuthCallback.isRunning()).toBe(true) - - await McpOAuthCallback.ensureRunning("http://127.0.0.1:18003/path2") - expect(McpOAuthCallback.isRunning()).toBe(true) - }) - - test("isRunning returns false when not started", async () => { - expect(McpOAuthCallback.isRunning()).toBe(false) - }) - - test("isRunning returns false after stop", async () => { - await McpOAuthCallback.ensureRunning() - await McpOAuthCallback.stop() - expect(McpOAuthCallback.isRunning()).toBe(false) - }) -}) - -describe("parseRedirectUri", () => { - test("returns defaults when no URI provided", () => { - const result = parseRedirectUri() - expect(result.port).toBe(19876) - expect(result.path).toBe("/mcp/oauth/callback") - }) - - test("parses port and path from URI", () => { - const result = parseRedirectUri("http://127.0.0.1:8080/oauth/callback") - expect(result.port).toBe(8080) - expect(result.path).toBe("/oauth/callback") - }) - - test("defaults to port 80 for http without explicit port", () => { - const result = parseRedirectUri("http://127.0.0.1/callback") - expect(result.port).toBe(80) - expect(result.path).toBe("/callback") - }) - - test("defaults to port 443 for https without explicit port", () => { - const result = parseRedirectUri("https://127.0.0.1/callback") - expect(result.port).toBe(443) - expect(result.path).toBe("/callback") - }) - - test("returns defaults for invalid URI", () => { - const result = parseRedirectUri("not-a-valid-url") - expect(result.port).toBe(19876) - expect(result.path).toBe("/mcp/oauth/callback") - }) -}) From 85ab9798c65dfb36b0f47de3b1fd786e7751f761 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 17 Jan 2026 04:18:35 +0000 Subject: [PATCH 043/104] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 4 ---- packages/sdk/openapi.json | 4 ---- 2 files changed, 8 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 32321a7dfd8..e47c4f5f7f1 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1530,10 +1530,6 @@ export type McpOAuthConfig = { * OAuth scopes to request during authorization */ scope?: string - /** - * OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback). - */ - redirectUri?: string } export type McpRemoteConfig = { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 904f3eeaefa..0dc174c1b0a 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -9122,10 +9122,6 @@ "scope": { "description": "OAuth scopes to request during authorization", "type": "string" - }, - "redirectUri": { - "description": "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).", - "type": "string" } }, "additionalProperties": false From ea13b6e8aa219f0c75eb13a6a58271d75c37521e Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 17 Jan 2026 00:35:49 -0600 Subject: [PATCH 044/104] test: add azure test case --- packages/opencode/src/provider/transform.ts | 2 +- .../opencode/test/provider/transform.test.ts | 82 +++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 71295ba2b67..79892db4cca 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -258,7 +258,7 @@ export namespace ProviderTransform { // Remap providerOptions keys from stored providerID to expected SDK key const key = sdkKey(model.api.npm) - if (key && key !== model.providerID) { + if (key && key !== model.providerID && model.api.npm !== "@ai-sdk/azure") { const remap = (opts: Record | undefined) => { if (!opts) return opts if (!(model.providerID in opts)) return opts diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 0f203b93022..dcf16c65cbd 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -914,6 +914,88 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( }) }) +describe("ProviderTransform.message - providerOptions key remapping", () => { + const createModel = (providerID: string, npm: string) => + ({ + id: `${providerID}/test-model`, + providerID, + api: { + id: "test-model", + url: "https://api.test.com", + npm, + }, + name: "Test Model", + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0.001, output: 0.002, cache: { read: 0.0001, write: 0.0002 } }, + limit: { context: 128000, output: 8192 }, + status: "active", + options: {}, + headers: {}, + }) as any + + test("azure keeps 'azure' key and does not remap to 'openai'", () => { + const model = createModel("azure", "@ai-sdk/azure") + const msgs = [ + { + role: "user", + content: "Hello", + providerOptions: { + azure: { someOption: "value" }, + }, + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) + + expect(result[0].providerOptions?.azure).toEqual({ someOption: "value" }) + expect(result[0].providerOptions?.openai).toBeUndefined() + }) + + test("openai with github-copilot npm remaps providerID to 'openai'", () => { + const model = createModel("github-copilot", "@ai-sdk/github-copilot") + const msgs = [ + { + role: "user", + content: "Hello", + providerOptions: { + "github-copilot": { someOption: "value" }, + }, + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) + + expect(result[0].providerOptions?.openai).toEqual({ someOption: "value" }) + expect(result[0].providerOptions?.["github-copilot"]).toBeUndefined() + }) + + test("bedrock remaps providerID to 'bedrock' key", () => { + const model = createModel("my-bedrock", "@ai-sdk/amazon-bedrock") + const msgs = [ + { + role: "user", + content: "Hello", + providerOptions: { + "my-bedrock": { someOption: "value" }, + }, + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) + + expect(result[0].providerOptions?.bedrock).toEqual({ someOption: "value" }) + expect(result[0].providerOptions?.["my-bedrock"]).toBeUndefined() + }) +}) + describe("ProviderTransform.variants", () => { const createMockModel = (overrides: Partial = {}): any => ({ id: "test/test-model", From 2729705594b9429ce44cf371dbf7268ac3457d8b Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sat, 17 Jan 2026 05:23:11 -0600 Subject: [PATCH 045/104] fix(app): archive session sometimes flaky --- packages/app/src/context/global-sync.tsx | 2 ++ packages/app/src/pages/layout.tsx | 12 ++++++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 74641a0a243..96f8c63eab2 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -379,6 +379,8 @@ function createGlobalSync() { }), ) } + if (event.properties.info.parentID) break + setStore("sessionTotal", (value) => Math.max(0, value - 1)) break } if (result.found) { diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index bc62c70232f..eb09b154b9c 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -501,7 +501,7 @@ export default function Layout(props: ParentProps) { const [dirStore] = globalSync.child(dir) const dirSessions = dirStore.session .filter((session) => session.directory === dirStore.path.directory) - .filter((session) => !session.parentID) + .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions) result.push(...dirSessions) } @@ -510,7 +510,7 @@ export default function Layout(props: ParentProps) { const [projectStore] = globalSync.child(project.worktree) return projectStore.session .filter((session) => session.directory === projectStore.path.directory) - .filter((session) => !session.parentID) + .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions) }) @@ -1203,7 +1203,7 @@ export default function Layout(props: ParentProps) { const sessions = createMemo(() => workspaceStore.session .filter((session) => session.directory === workspaceStore.path.directory) - .filter((session) => !session.parentID) + .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions), ) const local = createMemo(() => props.directory === props.project.worktree) @@ -1349,7 +1349,7 @@ export default function Layout(props: ParentProps) { const [data] = globalSync.child(directory) return data.session .filter((session) => session.directory === data.path.directory) - .filter((session) => !session.parentID) + .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions) .slice(0, 2) } @@ -1358,7 +1358,7 @@ export default function Layout(props: ParentProps) { const [data] = globalSync.child(props.project.worktree) return data.session .filter((session) => session.directory === data.path.directory) - .filter((session) => !session.parentID) + .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions) .slice(0, 2) } @@ -1445,7 +1445,7 @@ export default function Layout(props: ParentProps) { const sessions = createMemo(() => workspaceStore.session .filter((session) => session.directory === workspaceStore.path.directory) - .filter((session) => !session.parentID) + .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions), ) const loading = createMemo(() => workspaceStore.status !== "complete" && sessions().length === 0) From c4e4f2a0586df665988cd4afcecb810df4995627 Mon Sep 17 00:00:00 2001 From: Eric Guo Date: Sat, 17 Jan 2026 19:45:31 +0800 Subject: [PATCH 046/104] fix(desktop): Added a Windows-only guard that makes window.getComputedStyle fall back to document.documentElement (#9054) --- packages/desktop/src/index.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 7a46ba8cde0..8398f457766 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -26,6 +26,18 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) { ) } +const isWindows = ostype() === "windows" +if (isWindows) { + const originalGetComputedStyle = window.getComputedStyle + window.getComputedStyle = ((elt: Element, pseudoElt?: string | null) => { + if (!(elt instanceof Element)) { + // WebView2 can call into Floating UI with non-elements; fall back to a safe element. + return originalGetComputedStyle(document.documentElement, pseudoElt ?? undefined) + } + return originalGetComputedStyle(elt, pseudoElt ?? undefined) + }) as typeof window.getComputedStyle +} + let update: Update | null = null const createPlatform = (password: Accessor): Platform => ({ From 7030f49a7485e7b8f2f553351019b778dca64af9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Ace=C3=B1a?= <40375118+j0nl1@users.noreply.github.com> Date: Sat, 17 Jan 2026 12:47:19 +0100 Subject: [PATCH 047/104] fix: mdns discover hostname (#9039) --- packages/opencode/src/cli/cmd/web.ts | 2 +- packages/opencode/src/server/mdns.ts | 4 +++- packages/opencode/src/server/server.ts | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 2c207ecc2f2..d799b7cc25e 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -60,7 +60,7 @@ export const WebCommand = cmd({ } if (opts.mdns) { - UI.println(UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, "opencode.local") + UI.println(UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, `opencode.local:${server.port}`) } // Open localhost in browser diff --git a/packages/opencode/src/server/mdns.ts b/packages/opencode/src/server/mdns.ts index 8bddb910503..953269de444 100644 --- a/packages/opencode/src/server/mdns.ts +++ b/packages/opencode/src/server/mdns.ts @@ -7,15 +7,17 @@ export namespace MDNS { let bonjour: Bonjour | undefined let currentPort: number | undefined - export function publish(port: number, name = "opencode") { + export function publish(port: number) { if (currentPort === port) return if (bonjour) unpublish() try { + const name = `opencode-${port}` bonjour = new Bonjour() const service = bonjour.publish({ name, type: "http", + host: "opencode.local", port, txt: { path: "/" }, }) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index f0c64b49f81..28dec7f4043 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -562,7 +562,7 @@ export namespace Server { opts.hostname !== "localhost" && opts.hostname !== "::1" if (shouldPublishMDNS) { - MDNS.publish(server.port!, `opencode-${server.port!}`) + MDNS.publish(server.port!) } else if (opts.mdns) { log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") } From d37724649182ef26fd158cd4d36c9e6f97a6ea5c Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 17 Jan 2026 11:47:55 +0000 Subject: [PATCH 048/104] chore: generate --- packages/opencode/src/cli/cmd/web.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index d799b7cc25e..5fa2bb42640 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -60,7 +60,11 @@ export const WebCommand = cmd({ } if (opts.mdns) { - UI.println(UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, `opencode.local:${server.port}`) + UI.println( + UI.Style.TEXT_INFO_BOLD + " mDNS: ", + UI.Style.TEXT_NORMAL, + `opencode.local:${server.port}`, + ) } // Open localhost in browser From 07dc8d8ce48300e42c4cd5026fac609be1b2aaca Mon Sep 17 00:00:00 2001 From: Slone <50995948+Slone123c@users.noreply.github.com> Date: Sat, 17 Jan 2026 19:48:38 +0800 Subject: [PATCH 049/104] fix: escape CSS selector keys to handle special characters (#9030) --- packages/ui/src/components/list.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 5be7f95aeec..631b3e33a29 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -69,7 +69,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) if (!props.current) return const key = props.key(props.current) requestAnimationFrame(() => { - const element = scrollRef()?.querySelector(`[data-key="${key}"]`) + const element = scrollRef()?.querySelector(`[data-key="${CSS.escape(key)}"]`) element?.scrollIntoView({ block: "center" }) }) }) @@ -81,7 +81,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) scrollRef()?.scrollTo(0, 0) return } - const element = scrollRef()?.querySelector(`[data-key="${active()}"]`) + const element = scrollRef()?.querySelector(`[data-key="${CSS.escape(active()!)}"]`) element?.scrollIntoView({ block: "center" }) }) From a58d1be8226c749f015047c15c47950735cd9370 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 17 Jan 2026 12:04:18 +0000 Subject: [PATCH 050/104] ignore: update download stats 2026-01-17 --- STATS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/STATS.md b/STATS.md index e09c57e8f41..9a665612b14 100644 --- a/STATS.md +++ b/STATS.md @@ -202,3 +202,4 @@ | 2026-01-13 | 3,297,078 (+243,484) | 1,595,062 (+41,391) | 4,892,140 (+284,875) | | 2026-01-14 | 3,568,928 (+271,850) | 1,645,362 (+50,300) | 5,214,290 (+322,150) | | 2026-01-16 | 4,121,550 (+552,622) | 1,754,418 (+109,056) | 5,875,968 (+661,678) | +| 2026-01-17 | 4,389,558 (+268,008) | 1,805,315 (+50,897) | 6,194,873 (+318,905) | From a813fcb41cb43f463c2fd14dfe9d499c4d28c151 Mon Sep 17 00:00:00 2001 From: Colby Gilbert Date: Sat, 17 Jan 2026 11:04:43 -0800 Subject: [PATCH 051/104] docs: add firmware provider to providers docs (#8993) --- packages/web/src/content/docs/providers.mdx | 27 +++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index e1d684de00a..6022d174a7d 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -558,6 +558,33 @@ Cloudflare AI Gateway lets you access models from OpenAI, Anthropic, Workers AI, --- +### Firmware + +1. Head over to the [Firmware dashboard](https://app.firmware.ai/signup), create an account, and generate an API key. + +2. Run the `/connect` command and search for **Firmware**. + + ```txt + /connect + ``` + +3. Enter your Firmware API key. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Run the `/models` command to select a model. + + ```txt + /models + ``` + +--- + ### Fireworks AI 1. Head over to the [Fireworks AI console](https://app.fireworks.ai/), create an account, and click **Create API Key**. From eb968a6651e2af4b806ca8b466a82b11b04c56a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bernat=20Peric=C3=A0s?= Date: Sat, 17 Jan 2026 20:07:03 +0100 Subject: [PATCH 052/104] docs(config): explain that `autoupdate` doesn't work when installed with a package manager (#9092) --- packages/web/src/content/docs/config.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 30edbbd2146..eeb41b943c9 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -425,6 +425,7 @@ OpenCode will automatically download any new updates when it starts up. You can ``` If you don't want updates but want to be notified when a new version is available, set `autoupdate` to `"notify"`. +Notice that this only works if it was not installed using a package manager such as Homebrew. --- From 5a199b04cbbc8a582d8130d4d8e37e052094cc50 Mon Sep 17 00:00:00 2001 From: Rahul Mishra Date: Sun, 18 Jan 2026 00:38:11 +0530 Subject: [PATCH 053/104] fix: don't try to open command palette if a dialog is already open (#9116) --- packages/app/src/context/command.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index a93ffc02454..d8dc13e2344 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -1,5 +1,6 @@ import { createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" +import { useDialog } from "@opencode-ai/ui/context/dialog" const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) @@ -122,6 +123,7 @@ export function formatKeybind(config: string): string { export const { use: useCommand, provider: CommandProvider } = createSimpleContext({ name: "Command", init: () => { + const dialog = useDialog() const [registrations, setRegistrations] = createSignal[]>([]) const [suspendCount, setSuspendCount] = createSignal(0) @@ -165,7 +167,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex } const handleKeyDown = (event: KeyboardEvent) => { - if (suspended()) return + if (suspended() || dialog.active) return const paletteKeybinds = parseKeybind("mod+shift+p") if (matchKeybind(paletteKeybinds, event)) { From 58f7da6e9f8fb08a9f42a0d2cc34a18b2475ddea Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 17 Jan 2026 13:09:27 -0600 Subject: [PATCH 054/104] docs: document the plural forms --- packages/web/src/content/docs/agents.mdx | 12 +++++----- packages/web/src/content/docs/commands.mdx | 24 +++++++++---------- packages/web/src/content/docs/config.mdx | 10 +++++--- .../web/src/content/docs/custom-tools.mdx | 16 ++++++------- packages/web/src/content/docs/modes.mdx | 12 +++++----- packages/web/src/content/docs/permissions.mdx | 2 +- packages/web/src/content/docs/plugins.mdx | 24 +++++++++---------- packages/web/src/content/docs/skills.mdx | 10 ++++---- 8 files changed, 57 insertions(+), 53 deletions(-) diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index b85bd2142fa..22bed7f16a4 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -157,10 +157,10 @@ Configure agents in your `opencode.json` config file: You can also define agents using markdown files. Place them in: -- Global: `~/.config/opencode/agent/` -- Per-project: `.opencode/agent/` +- Global: `~/.config/opencode/agents/` +- Per-project: `.opencode/agents/` -```markdown title="~/.config/opencode/agent/review.md" +```markdown title="~/.config/opencode/agents/review.md" --- description: Reviews code for quality and best practices mode: subagent @@ -419,7 +419,7 @@ You can override these permissions per agent. You can also set permissions in Markdown agents. -```markdown title="~/.config/opencode/agent/review.md" +```markdown title="~/.config/opencode/agents/review.md" --- description: Code review without edits mode: subagent @@ -637,7 +637,7 @@ Do you have an agent you'd like to share? [Submit a PR](https://github.com/anoma ### Documentation agent -```markdown title="~/.config/opencode/agent/docs-writer.md" +```markdown title="~/.config/opencode/agents/docs-writer.md" --- description: Writes and maintains project documentation mode: subagent @@ -659,7 +659,7 @@ Focus on: ### Security auditor -```markdown title="~/.config/opencode/agent/security-auditor.md" +```markdown title="~/.config/opencode/agents/security-auditor.md" --- description: Performs security audits and identifies vulnerabilities mode: subagent diff --git a/packages/web/src/content/docs/commands.mdx b/packages/web/src/content/docs/commands.mdx index 92ca08bd2e9..1d7e4f1c21a 100644 --- a/packages/web/src/content/docs/commands.mdx +++ b/packages/web/src/content/docs/commands.mdx @@ -15,11 +15,11 @@ Custom commands are in addition to the built-in commands like `/init`, `/undo`, ## Create command files -Create markdown files in the `command/` directory to define custom commands. +Create markdown files in the `commands/` directory to define custom commands. -Create `.opencode/command/test.md`: +Create `.opencode/commands/test.md`: -```md title=".opencode/command/test.md" +```md title=".opencode/commands/test.md" --- description: Run tests with coverage agent: build @@ -42,7 +42,7 @@ Use the command by typing `/` followed by the command name. ## Configure -You can add custom commands through the OpenCode config or by creating markdown files in the `command/` directory. +You can add custom commands through the OpenCode config or by creating markdown files in the `commands/` directory. --- @@ -79,10 +79,10 @@ Now you can run this command in the TUI: You can also define commands using markdown files. Place them in: -- Global: `~/.config/opencode/command/` -- Per-project: `.opencode/command/` +- Global: `~/.config/opencode/commands/` +- Per-project: `.opencode/commands/` -```markdown title="~/.config/opencode/command/test.md" +```markdown title="~/.config/opencode/commands/test.md" --- description: Run tests with coverage agent: build @@ -112,7 +112,7 @@ The prompts for the custom commands support several special placeholders and syn Pass arguments to commands using the `$ARGUMENTS` placeholder. -```md title=".opencode/command/component.md" +```md title=".opencode/commands/component.md" --- description: Create a new component --- @@ -138,7 +138,7 @@ You can also access individual arguments using positional parameters: For example: -```md title=".opencode/command/create-file.md" +```md title=".opencode/commands/create-file.md" --- description: Create a new file with content --- @@ -167,7 +167,7 @@ Use _!`command`_ to inject [bash command](/docs/tui#bash-commands) output into y For example, to create a custom command that analyzes test coverage: -```md title=".opencode/command/analyze-coverage.md" +```md title=".opencode/commands/analyze-coverage.md" --- description: Analyze test coverage --- @@ -180,7 +180,7 @@ Based on these results, suggest improvements to increase coverage. Or to review recent changes: -```md title=".opencode/command/review-changes.md" +```md title=".opencode/commands/review-changes.md" --- description: Review recent changes --- @@ -199,7 +199,7 @@ Commands run in your project's root directory and their output becomes part of t Include files in your command using `@` followed by the filename. -```md title=".opencode/command/review-component.md" +```md title=".opencode/commands/review-component.md" --- description: Review component --- diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index eeb41b943c9..1474cb91558 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -51,6 +51,10 @@ Config sources are loaded in this order (later sources override earlier ones): This means project configs can override global defaults, and global configs can override remote organizational defaults. +:::note +The `.opencode` and `~/.config/opencode` directories use **plural names** for subdirectories: `agents/`, `commands/`, `modes/`, `plugins/`, `skills/`, `tools/`, and `themes/`. Singular names (e.g., `agent/`) are also supported for backwards compatibility. +::: + --- ### Remote @@ -330,7 +334,7 @@ You can configure specialized agents for specific tasks through the `agent` opti } ``` -You can also define agents using markdown files in `~/.config/opencode/agent/` or `.opencode/agent/`. [Learn more here](/docs/agents). +You can also define agents using markdown files in `~/.config/opencode/agents/` or `.opencode/agents/`. [Learn more here](/docs/agents). --- @@ -394,7 +398,7 @@ You can configure custom commands for repetitive tasks through the `command` opt } ``` -You can also define commands using markdown files in `~/.config/opencode/command/` or `.opencode/command/`. [Learn more here](/docs/commands). +You can also define commands using markdown files in `~/.config/opencode/commands/` or `.opencode/commands/`. [Learn more here](/docs/commands). --- @@ -530,7 +534,7 @@ You can configure MCP servers you want to use through the `mcp` option. [Plugins](/docs/plugins) extend OpenCode with custom tools, hooks, and integrations. -Place plugin files in `.opencode/plugin/` or `~/.config/opencode/plugin/`. You can also load plugins from npm through the `plugin` option. +Place plugin files in `.opencode/plugins/` or `~/.config/opencode/plugins/`. You can also load plugins from npm through the `plugin` option. ```json title="opencode.json" { diff --git a/packages/web/src/content/docs/custom-tools.mdx b/packages/web/src/content/docs/custom-tools.mdx index 2701be65086..e089a035b4b 100644 --- a/packages/web/src/content/docs/custom-tools.mdx +++ b/packages/web/src/content/docs/custom-tools.mdx @@ -17,8 +17,8 @@ Tools are defined as **TypeScript** or **JavaScript** files. However, the tool d They can be defined: -- Locally by placing them in the `.opencode/tool/` directory of your project. -- Or globally, by placing them in `~/.config/opencode/tool/`. +- Locally by placing them in the `.opencode/tools/` directory of your project. +- Or globally, by placing them in `~/.config/opencode/tools/`. --- @@ -26,7 +26,7 @@ They can be defined: The easiest way to create tools is using the `tool()` helper which provides type-safety and validation. -```ts title=".opencode/tool/database.ts" {1} +```ts title=".opencode/tools/database.ts" {1} import { tool } from "@opencode-ai/plugin" export default tool({ @@ -49,7 +49,7 @@ The **filename** becomes the **tool name**. The above creates a `database` tool. You can also export multiple tools from a single file. Each export becomes **a separate tool** with the name **`_`**: -```ts title=".opencode/tool/math.ts" +```ts title=".opencode/tools/math.ts" import { tool } from "@opencode-ai/plugin" export const add = tool({ @@ -112,7 +112,7 @@ export default { Tools receive context about the current session: -```ts title=".opencode/tool/project.ts" {8} +```ts title=".opencode/tools/project.ts" {8} import { tool } from "@opencode-ai/plugin" export default tool({ @@ -136,7 +136,7 @@ You can write your tools in any language you want. Here's an example that adds t First, create the tool as a Python script: -```python title=".opencode/tool/add.py" +```python title=".opencode/tools/add.py" import sys a = int(sys.argv[1]) @@ -146,7 +146,7 @@ print(a + b) Then create the tool definition that invokes it: -```ts title=".opencode/tool/python-add.ts" {10} +```ts title=".opencode/tools/python-add.ts" {10} import { tool } from "@opencode-ai/plugin" export default tool({ @@ -156,7 +156,7 @@ export default tool({ b: tool.schema.number().describe("Second number"), }, async execute(args) { - const result = await Bun.$`python3 .opencode/tool/add.py ${args.a} ${args.b}`.text() + const result = await Bun.$`python3 .opencode/tools/add.py ${args.a} ${args.b}`.text() return result.trim() }, }) diff --git a/packages/web/src/content/docs/modes.mdx b/packages/web/src/content/docs/modes.mdx index a31a8223b07..57c1c54a956 100644 --- a/packages/web/src/content/docs/modes.mdx +++ b/packages/web/src/content/docs/modes.mdx @@ -87,10 +87,10 @@ Configure modes in your `opencode.json` config file: You can also define modes using markdown files. Place them in: -- Global: `~/.config/opencode/mode/` -- Project: `.opencode/mode/` +- Global: `~/.config/opencode/modes/` +- Project: `.opencode/modes/` -```markdown title="~/.config/opencode/mode/review.md" +```markdown title="~/.config/opencode/modes/review.md" --- model: anthropic/claude-sonnet-4-20250514 temperature: 0.1 @@ -268,9 +268,9 @@ You can create your own custom modes by adding them to the configuration. Here a ### Using markdown files -Create mode files in `.opencode/mode/` for project-specific modes or `~/.config/opencode/mode/` for global modes: +Create mode files in `.opencode/modes/` for project-specific modes or `~/.config/opencode/modes/` for global modes: -```markdown title=".opencode/mode/debug.md" +```markdown title=".opencode/modes/debug.md" --- temperature: 0.1 tools: @@ -294,7 +294,7 @@ Focus on: Do not make any changes to files. Only investigate and report. ``` -```markdown title="~/.config/opencode/mode/refactor.md" +```markdown title="~/.config/opencode/modes/refactor.md" --- model: anthropic/claude-sonnet-4-20250514 temperature: 0.2 diff --git a/packages/web/src/content/docs/permissions.mdx b/packages/web/src/content/docs/permissions.mdx index b4f0691ced7..4df3841e34a 100644 --- a/packages/web/src/content/docs/permissions.mdx +++ b/packages/web/src/content/docs/permissions.mdx @@ -174,7 +174,7 @@ Refer to the [Granular Rules (Object Syntax)](#granular-rules-object-syntax) sec You can also configure agent permissions in Markdown: -```markdown title="~/.config/opencode/agent/review.md" +```markdown title="~/.config/opencode/agents/review.md" --- description: Code review without edits mode: subagent diff --git a/packages/web/src/content/docs/plugins.mdx b/packages/web/src/content/docs/plugins.mdx index bf26744f6c4..66a1b3cad95 100644 --- a/packages/web/src/content/docs/plugins.mdx +++ b/packages/web/src/content/docs/plugins.mdx @@ -19,8 +19,8 @@ There are two ways to load plugins. Place JavaScript or TypeScript files in the plugin directory. -- `.opencode/plugin/` - Project-level plugins -- `~/.config/opencode/plugin/` - Global plugins +- `.opencode/plugins/` - Project-level plugins +- `~/.config/opencode/plugins/` - Global plugins Files in these directories are automatically loaded at startup. @@ -57,8 +57,8 @@ Plugins are loaded from all sources and all hooks run in sequence. The load orde 1. Global config (`~/.config/opencode/opencode.json`) 2. Project config (`opencode.json`) -3. Global plugin directory (`~/.config/opencode/plugin/`) -4. Project plugin directory (`.opencode/plugin/`) +3. Global plugin directory (`~/.config/opencode/plugins/`) +4. Project plugin directory (`.opencode/plugins/`) Duplicate npm packages with the same name and version are loaded once. However, a local plugin and an npm plugin with similar names are both loaded separately. @@ -85,7 +85,7 @@ Local plugins and custom tools can use external npm packages. Add a `package.jso OpenCode runs `bun install` at startup to install these. Your plugins and tools can then import them. -```ts title=".opencode/plugin/my-plugin.ts" +```ts title=".opencode/plugins/my-plugin.ts" import { escape } from "shescape" export const MyPlugin = async (ctx) => { @@ -103,7 +103,7 @@ export const MyPlugin = async (ctx) => { ### Basic structure -```js title=".opencode/plugin/example.js" +```js title=".opencode/plugins/example.js" export const MyPlugin = async ({ project, client, $, directory, worktree }) => { console.log("Plugin initialized!") @@ -215,7 +215,7 @@ Here are some examples of plugins you can use to extend opencode. Send notifications when certain events occur: -```js title=".opencode/plugin/notification.js" +```js title=".opencode/plugins/notification.js" export const NotificationPlugin = async ({ project, client, $, directory, worktree }) => { return { event: async ({ event }) => { @@ -240,7 +240,7 @@ If you’re using the OpenCode desktop app, it can send system notifications aut Prevent opencode from reading `.env` files: -```javascript title=".opencode/plugin/env-protection.js" +```javascript title=".opencode/plugins/env-protection.js" export const EnvProtection = async ({ project, client, $, directory, worktree }) => { return { "tool.execute.before": async (input, output) => { @@ -258,7 +258,7 @@ export const EnvProtection = async ({ project, client, $, directory, worktree }) Plugins can also add custom tools to opencode: -```ts title=".opencode/plugin/custom-tools.ts" +```ts title=".opencode/plugins/custom-tools.ts" import { type Plugin, tool } from "@opencode-ai/plugin" export const CustomToolsPlugin: Plugin = async (ctx) => { @@ -292,7 +292,7 @@ Your custom tools will be available to opencode alongside built-in tools. Use `client.app.log()` instead of `console.log` for structured logging: -```ts title=".opencode/plugin/my-plugin.ts" +```ts title=".opencode/plugins/my-plugin.ts" export const MyPlugin = async ({ client }) => { await client.app.log({ service: "my-plugin", @@ -311,7 +311,7 @@ Levels: `debug`, `info`, `warn`, `error`. See [SDK documentation](https://openco Customize the context included when a session is compacted: -```ts title=".opencode/plugin/compaction.ts" +```ts title=".opencode/plugins/compaction.ts" import type { Plugin } from "@opencode-ai/plugin" export const CompactionPlugin: Plugin = async (ctx) => { @@ -335,7 +335,7 @@ The `experimental.session.compacting` hook fires before the LLM generates a cont You can also replace the compaction prompt entirely by setting `output.prompt`: -```ts title=".opencode/plugin/custom-compaction.ts" +```ts title=".opencode/plugins/custom-compaction.ts" import type { Plugin } from "@opencode-ai/plugin" export const CustomCompactionPlugin: Plugin = async (ctx) => { diff --git a/packages/web/src/content/docs/skills.mdx b/packages/web/src/content/docs/skills.mdx index 54c2c9d06ef..553931eec49 100644 --- a/packages/web/src/content/docs/skills.mdx +++ b/packages/web/src/content/docs/skills.mdx @@ -13,8 +13,8 @@ Skills are loaded on-demand via the native `skill` tool—agents see available s Create one folder per skill name and put a `SKILL.md` inside it. OpenCode searches these locations: -- Project config: `.opencode/skill//SKILL.md` -- Global config: `~/.config/opencode/skill//SKILL.md` +- Project config: `.opencode/skills//SKILL.md` +- Global config: `~/.config/opencode/skills//SKILL.md` - Project Claude-compatible: `.claude/skills//SKILL.md` - Global Claude-compatible: `~/.claude/skills//SKILL.md` @@ -23,9 +23,9 @@ OpenCode searches these locations: ## Understand discovery For project-local paths, OpenCode walks up from your current working directory until it reaches the git worktree. -It loads any matching `skill/*/SKILL.md` in `.opencode/` and any matching `.claude/skills/*/SKILL.md` along the way. +It loads any matching `skills/*/SKILL.md` in `.opencode/` and any matching `.claude/skills/*/SKILL.md` along the way. -Global definitions are also loaded from `~/.config/opencode/skill/*/SKILL.md` and `~/.claude/skills/*/SKILL.md`. +Global definitions are also loaded from `~/.config/opencode/skills/*/SKILL.md` and `~/.claude/skills/*/SKILL.md`. --- @@ -71,7 +71,7 @@ Keep it specific enough for the agent to choose correctly. ## Use an example -Create `.opencode/skill/git-release/SKILL.md` like this: +Create `.opencode/skills/git-release/SKILL.md` like this: ```markdown --- From 3aff88c23d139f47af7f4db7bbc14e08a30f3b6e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Sat, 17 Jan 2026 13:36:54 -0600 Subject: [PATCH 055/104] docs: add use_github_token to example (#9120) Co-authored-by: opencode-agent[bot] Co-authored-by: rekram1-node --- github/README.md | 2 ++ packages/web/src/content/docs/github.mdx | 2 ++ 2 files changed, 4 insertions(+) diff --git a/github/README.md b/github/README.md index 8238bdc42aa..17b24ffb1d6 100644 --- a/github/README.md +++ b/github/README.md @@ -91,8 +91,10 @@ This will walk you through installing the GitHub app, creating the workflow, and uses: anomalyco/opencode/github@latest env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: model: anthropic/claude-sonnet-4-20250514 + use_github_token: true ``` 3. Store the API keys in secrets. In your organization or project **settings**, expand **Secrets and variables** on the left and select **Actions**. Add the required API keys. diff --git a/packages/web/src/content/docs/github.mdx b/packages/web/src/content/docs/github.mdx index 6e8b9de4d79..a31fe1e7be8 100644 --- a/packages/web/src/content/docs/github.mdx +++ b/packages/web/src/content/docs/github.mdx @@ -180,8 +180,10 @@ jobs: - uses: anomalyco/opencode/github@latest env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: model: anthropic/claude-sonnet-4-20250514 + use_github_token: true prompt: | Review this pull request: - Check for code quality issues From f3513bacffc83355369ccf5b7e8c264dc764d901 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 17 Jan 2026 14:41:42 -0600 Subject: [PATCH 056/104] tui: fix model state persistence when model store is not ready --- packages/opencode/src/cli/cmd/tui/context/local.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 63f1d9743bf..d058ce54fb3 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -113,8 +113,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) const file = Bun.file(path.join(Global.Path.state, "model.json")) + const state = { + pending: false, + } function save() { + if (!modelStore.ready) { + state.pending = true + return + } + state.pending = false Bun.write( file, JSON.stringify({ @@ -135,6 +143,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ .catch(() => {}) .finally(() => { setModelStore("ready", true) + if (state.pending) save() }) const args = useArgs() From c3d33562c705445e258648bb0d34e94fea7fe68f Mon Sep 17 00:00:00 2001 From: David Hill Date: Sat, 17 Jan 2026 21:30:38 +0000 Subject: [PATCH 057/104] fix: align project avatar notification dot --- packages/app/src/pages/layout.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index eb09b154b9c..27535c01b0d 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1018,7 +1018,7 @@ export default function Layout(props: ParentProps) { const notifications = createMemo(() => notification.project.unseen(props.project.worktree)) const hasError = createMemo(() => notifications().some((n) => n.type === "error")) const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) - const mask = "radial-gradient(circle 6px at calc(100% - 3px) 3px, transparent 6px, black 6.5px)" + const mask = "radial-gradient(circle 6px at calc(100% - 5px) 5px, transparent 6px, black 6.5px)" const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" return ( @@ -1039,7 +1039,7 @@ export default function Layout(props: ParentProps) { 0 && props.notify}>
Date: Sat, 17 Jan 2026 21:41:01 +0000 Subject: [PATCH 058/104] fix: update desktop initializing splash logo --- packages/desktop/src/index.tsx | 5 ++--- packages/ui/src/components/logo.tsx | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 8398f457766..0d9e383790a 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -12,7 +12,7 @@ import { relaunch } from "@tauri-apps/plugin-process" import { AsyncStorage } from "@solid-primitives/storage" import { fetch as tauriFetch } from "@tauri-apps/plugin-http" import { Store } from "@tauri-apps/plugin-store" -import { Logo } from "@opencode-ai/ui/logo" +import { Splash } from "@opencode-ai/ui/logo" import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js" import { UPDATER_ENABLED } from "./updater" @@ -357,8 +357,7 @@ function ServerGate(props: { children: (data: Accessor) => JSX. when={serverData.state !== "pending" && serverData()} fallback={
- -
Initializing...
+
} > diff --git a/packages/ui/src/components/logo.tsx b/packages/ui/src/components/logo.tsx index 5ddf3fba329..26f312bda75 100644 --- a/packages/ui/src/components/logo.tsx +++ b/packages/ui/src/components/logo.tsx @@ -13,6 +13,21 @@ export const Mark = (props: { class?: string }) => { ) } +export const Splash = (props: { class?: string }) => { + return ( + + + + + ) +} + export const Logo = (props: { class?: string }) => { return ( Date: Sat, 17 Jan 2026 21:43:03 +0000 Subject: [PATCH 059/104] fix: shrink project notification dot and mask --- packages/app/src/pages/layout.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 27535c01b0d..0e46298e7ed 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1018,7 +1018,7 @@ export default function Layout(props: ParentProps) { const notifications = createMemo(() => notification.project.unseen(props.project.worktree)) const hasError = createMemo(() => notifications().some((n) => n.type === "error")) const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) - const mask = "radial-gradient(circle 6px at calc(100% - 5px) 5px, transparent 6px, black 6.5px)" + const mask = "radial-gradient(circle 5px at calc(100% - 4px) 4px, transparent 5px, black 5.5px)" const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" return ( @@ -1039,7 +1039,7 @@ export default function Layout(props: ParentProps) { 0 && props.notify}>
Date: Sat, 17 Jan 2026 21:46:21 +0000 Subject: [PATCH 060/104] fix: command pallete file list item spacing --- packages/app/src/components/dialog-select-file.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 432e531e192..0e8d69628bb 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -149,7 +149,7 @@ export function DialogSelectFile() { +
From b4075cd8565cc95c5417f8e578167515c71c4a01 Mon Sep 17 00:00:00 2001 From: David Hill Date: Sat, 17 Jan 2026 21:54:51 +0000 Subject: [PATCH 061/104] fix: remove loading text after splash --- packages/app/src/app.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index d0678dc5369..d03d10d0ea7 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -29,7 +29,7 @@ import { Suspense } from "solid-js" const Home = lazy(() => import("@/pages/home")) const Session = lazy(() => import("@/pages/session")) -const Loading = () =>
Loading...
+const Loading = () =>
declare global { interface Window { From 0cc9a22a428498fbbd8414d1b9fbc5263fa58383 Mon Sep 17 00:00:00 2001 From: David Hill Date: Sat, 17 Jan 2026 22:51:49 +0000 Subject: [PATCH 062/104] fix: show project name in avatar hover --- packages/app/src/pages/layout.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 0e46298e7ed..25c94554069 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1383,7 +1383,8 @@ export default function Layout(props: ParentProps) {
-
Recent sessions
+
{displayName(props.project)}
+
Recent sessions
Date: Sat, 17 Jan 2026 22:54:30 +0000 Subject: [PATCH 063/104] fix: reduce prompt dock bottom spacing --- packages/app/src/pages/session.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index aad0b596cb9..dbdbbc7eb55 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1091,7 +1091,7 @@ export default function Page() { file.load(path) }} classes={{ - root: "pb-[calc(var(--prompt-height,8rem)+32px)]", + root: "pb-[calc(var(--prompt-height,8rem)+24px)]", header: "px-4", container: "px-4", }} @@ -1237,7 +1237,7 @@ export default function Page() { {/* Prompt input */}
(promptDock = el)} - class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-8 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none" + class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-6 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none" >
Date: Sat, 17 Jan 2026 23:21:34 +0000 Subject: [PATCH 064/104] fix: adjust recent sessions popover padding --- packages/app/src/pages/layout.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 25c94554069..101cc2eaad8 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1383,8 +1383,8 @@ export default function Layout(props: ParentProps) {
-
{displayName(props.project)}
-
Recent sessions
+
{displayName(props.project)}
+
Recent sessions
Date: Sat, 17 Jan 2026 23:40:06 +0000 Subject: [PATCH 065/104] fix: keep project avatar hover styles while popover open --- packages/app/src/pages/layout.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 101cc2eaad8..98f62dc11b4 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1338,6 +1338,8 @@ export default function Layout(props: ParentProps) { const workspaces = createMemo(() => workspaceIds(props.project).slice(0, 2)) const workspaceEnabled = createMemo(() => layout.sidebar.workspaces(props.project.worktree)()) + const [open, setOpen] = createSignal(false) + const label = (directory: string) => { const [data] = globalSync.child(directory) const kind = directory === props.project.worktree ? "local" : "sandbox" @@ -1370,7 +1372,8 @@ export default function Layout(props: ParentProps) { "flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true, "bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(), "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base": - !selected(), + !selected() && !open(), + "bg-surface-base-hover border border-border-weak-base": !selected() && open(), }} onClick={() => navigateToProject(props.project.worktree)} > @@ -1381,7 +1384,14 @@ export default function Layout(props: ParentProps) { return ( // @ts-ignore
- +
{displayName(props.project)}
Recent sessions
From ded9bd26bba4d9c23d614519088083f006114bb4 Mon Sep 17 00:00:00 2001 From: David Hill Date: Sun, 18 Jan 2026 00:07:21 +0000 Subject: [PATCH 066/104] fix: adjust session list tooltip trigger and delay --- packages/app/src/pages/layout.tsx | 80 +++++++++++++++++-------------- 1 file changed, 43 insertions(+), 37 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 98f62dc11b4..f7182d26fea 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1089,33 +1089,39 @@ export default function Layout(props: ParentProps) { class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3 hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active" > - - prefetchSession(props.session, "high")} - onFocus={() => prefetchSession(props.session, "high")} - > -
-
- }> - - - - -
- - -
- - 0}> -
- - -
+
prefetchSession(props.session, "high")} + onFocus={() => prefetchSession(props.session, "high")} + > +
+
+ }> + + + + + +
From e36b3433fcabed407b3501f299e008173907ac9e Mon Sep 17 00:00:00 2001 From: David Hill Date: Sun, 18 Jan 2026 00:48:06 +0000 Subject: [PATCH 067/104] fix: remove max width on sidebar new buttons --- packages/app/src/pages/layout.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index f7182d26fea..26ca34f934e 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1669,7 +1669,7 @@ export default function Layout(props: ParentProps) {
From 389d97ece9a08907c2c101ba18ae5712dfe9b8fd Mon Sep 17 00:00:00 2001 From: David Hill Date: Sun, 18 Jan 2026 00:48:49 +0000 Subject: [PATCH 068/104] fix: adjust project path tooltip placement Move the desktop project path tooltip above the header and tune spacing/offset; add content style hooks to Tooltip for max-width and horizontal shift. --- packages/app/src/pages/layout.tsx | 11 ++++++++++- packages/ui/src/components/tooltip.tsx | 11 +++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 26ca34f934e..2a488ec4966 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1627,7 +1627,16 @@ export default function Layout(props: ParentProps) { stopPropagation /> - + {project()?.worktree.replace(homedir(), "~")} diff --git a/packages/ui/src/components/tooltip.tsx b/packages/ui/src/components/tooltip.tsx index c38ee5847db..711047030cc 100644 --- a/packages/ui/src/components/tooltip.tsx +++ b/packages/ui/src/components/tooltip.tsx @@ -5,6 +5,8 @@ import type { ComponentProps } from "solid-js" export interface TooltipProps extends ComponentProps { value: JSX.Element class?: string + contentClass?: string + contentStyle?: JSX.CSSProperties inactive?: boolean } @@ -30,7 +32,7 @@ export function TooltipKeybind(props: TooltipKeybindProps) { export function Tooltip(props: TooltipProps) { const [open, setOpen] = createSignal(false) - const [local, others] = splitProps(props, ["children", "class", "inactive"]) + const [local, others] = splitProps(props, ["children", "class", "contentClass", "contentStyle", "inactive"]) const c = children(() => local.children) @@ -58,7 +60,12 @@ export function Tooltip(props: TooltipProps) { {c()} - + {others.value} {/* */} From c3ab76c8ad90fb7ef99604e8ef0a1017c5ddda58 Mon Sep 17 00:00:00 2001 From: David Hill Date: Sun, 18 Jan 2026 00:51:35 +0000 Subject: [PATCH 069/104] fix: increase max-width of session name tooltip --- packages/app/src/pages/layout.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 2a488ec4966..6c860d98edc 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1119,7 +1119,8 @@ export default function Layout(props: ParentProps) { placement="top-start" value={props.session.title} gutter={0} - openDelay={2000} + openDelay={3000} + contentStyle={{ maxWidth: "640px" }} class="grow-1 min-w-0" > Date: Sun, 18 Jan 2026 00:57:21 +0000 Subject: [PATCH 070/104] Revert "fix: increase max-width of session name tooltip" This reverts commit c3ab76c8ad90fb7ef99604e8ef0a1017c5ddda58. --- packages/app/src/pages/layout.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 6c860d98edc..2a488ec4966 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1119,8 +1119,7 @@ export default function Layout(props: ParentProps) { placement="top-start" value={props.session.title} gutter={0} - openDelay={3000} - contentStyle={{ maxWidth: "640px" }} + openDelay={2000} class="grow-1 min-w-0" > Date: Sun, 18 Jan 2026 00:59:41 +0000 Subject: [PATCH 071/104] fix: remove max-width of session name tooltip --- packages/app/src/pages/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 2a488ec4966..56d6bfbf8ca 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1119,7 +1119,7 @@ export default function Layout(props: ParentProps) { placement="top-start" value={props.session.title} gutter={0} - openDelay={2000} + openDelay={3000} class="grow-1 min-w-0" > Date: Sat, 17 Jan 2026 20:32:57 -0600 Subject: [PATCH 072/104] Hide variants hint when list empty (#9179) Co-authored-by: opencode-agent[bot] Co-authored-by: rekram1-node --- .../opencode/src/cli/cmd/tui/component/prompt/index.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 145fa9da0c3..d61fbcfdd86 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1065,9 +1065,11 @@ export function Prompt(props: PromptProps) { - - {keybind.print("variant_cycle")} variants - + 0}> + + {keybind.print("variant_cycle")} variants + + {keybind.print("agent_cycle")} agents From 759e68616e53f4c4d3a647606203bf46a9193733 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 17 Jan 2026 21:39:19 -0500 Subject: [PATCH 073/104] refactor(tui): unify command registry and derive slash commands (#9115) --- packages/opencode/src/cli/cmd/tui/app.tsx | 45 +++++- .../cli/cmd/tui/component/dialog-command.tsx | 80 ++++++---- .../cmd/tui/component/prompt/autocomplete.tsx | 149 ++---------------- .../cli/cmd/tui/component/prompt/index.tsx | 22 +-- .../src/cli/cmd/tui/routes/session/index.tsx | 118 +++++++++----- .../src/cli/cmd/tui/ui/dialog-select.tsx | 2 +- 6 files changed, 194 insertions(+), 222 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 2ec1fb703f9..4b177e292cf 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -288,6 +288,10 @@ function App() { keybind: "session_list", category: "Session", suggested: sync.data.session.length > 0, + slash: { + name: "sessions", + aliases: ["resume", "continue"], + }, onSelect: () => { dialog.replace(() => ) }, @@ -298,6 +302,10 @@ function App() { value: "session.new", keybind: "session_new", category: "Session", + slash: { + name: "new", + aliases: ["clear"], + }, onSelect: () => { const current = promptRef.current // Don't require focus - if there's any text, preserve it @@ -315,26 +323,29 @@ function App() { keybind: "model_list", suggested: true, category: "Agent", + slash: { + name: "models", + }, onSelect: () => { dialog.replace(() => ) }, }, { title: "Model cycle", - disabled: true, value: "model.cycle_recent", keybind: "model_cycle_recent", category: "Agent", + hidden: true, onSelect: () => { local.model.cycle(1) }, }, { title: "Model cycle reverse", - disabled: true, value: "model.cycle_recent_reverse", keybind: "model_cycle_recent_reverse", category: "Agent", + hidden: true, onSelect: () => { local.model.cycle(-1) }, @@ -344,6 +355,7 @@ function App() { value: "model.cycle_favorite", keybind: "model_cycle_favorite", category: "Agent", + hidden: true, onSelect: () => { local.model.cycleFavorite(1) }, @@ -353,6 +365,7 @@ function App() { value: "model.cycle_favorite_reverse", keybind: "model_cycle_favorite_reverse", category: "Agent", + hidden: true, onSelect: () => { local.model.cycleFavorite(-1) }, @@ -362,6 +375,9 @@ function App() { value: "agent.list", keybind: "agent_list", category: "Agent", + slash: { + name: "agents", + }, onSelect: () => { dialog.replace(() => ) }, @@ -370,6 +386,9 @@ function App() { title: "Toggle MCPs", value: "mcp.list", category: "Agent", + slash: { + name: "mcps", + }, onSelect: () => { dialog.replace(() => ) }, @@ -379,7 +398,7 @@ function App() { value: "agent.cycle", keybind: "agent_cycle", category: "Agent", - disabled: true, + hidden: true, onSelect: () => { local.agent.move(1) }, @@ -389,6 +408,7 @@ function App() { value: "variant.cycle", keybind: "variant_cycle", category: "Agent", + hidden: true, onSelect: () => { local.model.variant.cycle() }, @@ -398,7 +418,7 @@ function App() { value: "agent.cycle.reverse", keybind: "agent_cycle_reverse", category: "Agent", - disabled: true, + hidden: true, onSelect: () => { local.agent.move(-1) }, @@ -407,6 +427,9 @@ function App() { title: "Connect provider", value: "provider.connect", suggested: !connected(), + slash: { + name: "connect", + }, onSelect: () => { dialog.replace(() => ) }, @@ -416,6 +439,9 @@ function App() { title: "View status", keybind: "status_view", value: "opencode.status", + slash: { + name: "status", + }, onSelect: () => { dialog.replace(() => ) }, @@ -425,6 +451,9 @@ function App() { title: "Switch theme", value: "theme.switch", keybind: "theme_list", + slash: { + name: "themes", + }, onSelect: () => { dialog.replace(() => ) }, @@ -442,6 +471,9 @@ function App() { { title: "Help", value: "help.show", + slash: { + name: "help", + }, onSelect: () => { dialog.replace(() => ) }, @@ -468,6 +500,10 @@ function App() { { title: "Exit the app", value: "app.exit", + slash: { + name: "exit", + aliases: ["quit", "q"], + }, onSelect: () => exit(), category: "System", }, @@ -508,6 +544,7 @@ function App() { value: "terminal.suspend", keybind: "terminal_suspend", category: "System", + hidden: true, onSelect: () => { process.once("SIGCONT", () => { renderer.resume() diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx index d19e93188b2..38dc402758b 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx @@ -16,9 +16,17 @@ import type { KeybindsConfig } from "@opencode-ai/sdk/v2" type Context = ReturnType const ctx = createContext() -export type CommandOption = DialogSelectOption & { +export type Slash = { + name: string + aliases?: string[] +} + +export type CommandOption = DialogSelectOption & { keybind?: keyof KeybindsConfig suggested?: boolean + slash?: Slash + hidden?: boolean + enabled?: boolean } function init() { @@ -26,27 +34,35 @@ function init() { const [suspendCount, setSuspendCount] = createSignal(0) const dialog = useDialog() const keybind = useKeybind() - const options = createMemo(() => { + + const entries = createMemo(() => { const all = registrations().flatMap((x) => x()) - const suggested = all.filter((x) => x.suggested) - return [ - ...suggested.map((x) => ({ - ...x, - category: "Suggested", - value: "suggested." + x.value, - })), - ...all, - ].map((x) => ({ + return all.map((x) => ({ ...x, footer: x.keybind ? keybind.print(x.keybind) : undefined, })) }) + + const isEnabled = (option: CommandOption) => option.enabled !== false + const isVisible = (option: CommandOption) => isEnabled(option) && !option.hidden + + const visibleOptions = createMemo(() => entries().filter((option) => isVisible(option))) + const suggestedOptions = createMemo(() => + visibleOptions() + .filter((option) => option.suggested) + .map((option) => ({ + ...option, + value: `suggested:${option.value}`, + category: "Suggested", + })), + ) const suspended = () => suspendCount() > 0 useKeyboard((evt) => { if (suspended()) return if (dialog.stack.length > 0) return - for (const option of options()) { + for (const option of entries()) { + if (!isEnabled(option)) continue if (option.keybind && keybind.match(option.keybind, evt)) { evt.preventDefault() option.onSelect?.(dialog) @@ -56,20 +72,33 @@ function init() { }) const result = { - trigger(name: string, source?: "prompt") { - for (const option of options()) { + trigger(name: string) { + for (const option of entries()) { if (option.value === name) { - option.onSelect?.(dialog, source) + if (!isEnabled(option)) return + option.onSelect?.(dialog) return } } }, + slashes() { + return visibleOptions().flatMap((option) => { + const slash = option.slash + if (!slash) return [] + return { + display: "/" + slash.name, + description: option.description ?? option.title, + aliases: slash.aliases?.map((alias) => "/" + alias), + onSelect: () => result.trigger(option.value), + } + }) + }, keybinds(enabled: boolean) { setSuspendCount((count) => count + (enabled ? -1 : 1)) }, suspended, show() { - dialog.replace(() => ) + dialog.replace(() => ) }, register(cb: () => CommandOption[]) { const results = createMemo(cb) @@ -78,9 +107,6 @@ function init() { setRegistrations((arr) => arr.filter((x) => x !== results)) }) }, - get options() { - return options() - }, } return result } @@ -104,7 +130,7 @@ export function CommandProvider(props: ParentProps) { if (evt.defaultPrevented) return if (keybind.match("command_list", evt)) { evt.preventDefault() - dialog.replace(() => ) + value.show() return } }) @@ -112,13 +138,11 @@ export function CommandProvider(props: ParentProps) { return {props.children} } -function DialogCommand(props: { options: CommandOption[] }) { +function DialogCommand(props: { options: CommandOption[]; suggestedOptions: CommandOption[] }) { let ref: DialogSelectRef - return ( - (ref = r)} - title="Commands" - options={props.options.filter((x) => !ref?.filter || !x.value.startsWith("suggested."))} - /> - ) + const list = () => { + if (ref?.filter) return props.options + return [...props.suggestedOptions, ...props.options] + } + return (ref = r)} title="Commands" options={list()} /> } diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 601eb82bc48..e27c32dfb2e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -332,16 +332,15 @@ export function Autocomplete(props: { ) }) - const session = createMemo(() => (props.sessionID ? sync.session.get(props.sessionID) : undefined)) const commands = createMemo((): AutocompleteOption[] => { - const results: AutocompleteOption[] = [] - const s = session() - for (const command of sync.data.command) { + const results: AutocompleteOption[] = [...command.slashes()] + + for (const serverCommand of sync.data.command) { results.push({ - display: "/" + command.name + (command.mcp ? " (MCP)" : ""), - description: command.description, + display: "/" + serverCommand.name + (serverCommand.mcp ? " (MCP)" : ""), + description: serverCommand.description, onSelect: () => { - const newText = "/" + command.name + " " + const newText = "/" + serverCommand.name + " " const cursor = props.input().logicalCursor props.input().deleteRange(0, 0, cursor.row, cursor.col) props.input().insertText(newText) @@ -349,138 +348,9 @@ export function Autocomplete(props: { }, }) } - if (s) { - results.push( - { - display: "/undo", - description: "undo the last message", - onSelect: () => { - command.trigger("session.undo") - }, - }, - { - display: "/redo", - description: "redo the last message", - onSelect: () => command.trigger("session.redo"), - }, - { - display: "/compact", - aliases: ["/summarize"], - description: "compact the session", - onSelect: () => command.trigger("session.compact"), - }, - { - display: "/unshare", - disabled: !s.share, - description: "unshare a session", - onSelect: () => command.trigger("session.unshare"), - }, - { - display: "/rename", - description: "rename session", - onSelect: () => command.trigger("session.rename"), - }, - { - display: "/copy", - description: "copy session transcript to clipboard", - onSelect: () => command.trigger("session.copy"), - }, - { - display: "/export", - description: "export session transcript to file", - onSelect: () => command.trigger("session.export"), - }, - { - display: "/timeline", - description: "jump to message", - onSelect: () => command.trigger("session.timeline"), - }, - { - display: "/fork", - description: "fork from message", - onSelect: () => command.trigger("session.fork"), - }, - { - display: "/thinking", - description: "toggle thinking visibility", - onSelect: () => command.trigger("session.toggle.thinking"), - }, - ) - if (sync.data.config.share !== "disabled") { - results.push({ - display: "/share", - disabled: !!s.share?.url, - description: "share a session", - onSelect: () => command.trigger("session.share"), - }) - } - } - results.push( - { - display: "/new", - aliases: ["/clear"], - description: "create a new session", - onSelect: () => command.trigger("session.new"), - }, - { - display: "/models", - description: "list models", - onSelect: () => command.trigger("model.list"), - }, - { - display: "/agents", - description: "list agents", - onSelect: () => command.trigger("agent.list"), - }, - { - display: "/session", - aliases: ["/resume", "/continue"], - description: "list sessions", - onSelect: () => command.trigger("session.list"), - }, - { - display: "/status", - description: "show status", - onSelect: () => command.trigger("opencode.status"), - }, - { - display: "/mcp", - description: "toggle MCPs", - onSelect: () => command.trigger("mcp.list"), - }, - { - display: "/theme", - description: "toggle theme", - onSelect: () => command.trigger("theme.switch"), - }, - { - display: "/editor", - description: "open editor", - onSelect: () => command.trigger("prompt.editor", "prompt"), - }, - { - display: "/connect", - description: "connect to a provider", - onSelect: () => command.trigger("provider.connect"), - }, - { - display: "/help", - description: "show help", - onSelect: () => command.trigger("help.show"), - }, - { - display: "/commands", - description: "show all commands", - onSelect: () => command.show(), - }, - { - display: "/exit", - aliases: ["/quit", "/q"], - description: "exit the app", - onSelect: () => command.trigger("app.exit"), - }, - ) + results.sort((a, b) => a.display.localeCompare(b.display)) + const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length if (!max) return results return results.map((item) => ({ @@ -494,9 +364,8 @@ export function Autocomplete(props: { const agentsValue = agents() const commandsValue = commands() - const mixed: AutocompleteOption[] = ( + const mixed: AutocompleteOption[] = store.visible === "@" ? [...agentsValue, ...(filesValue || []), ...mcpResources()] : [...commandsValue] - ).filter((x) => x.disabled !== true) const currentFilter = filter() diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index d61fbcfdd86..730da20c265 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -157,7 +157,7 @@ export function Prompt(props: PromptProps) { title: "Clear prompt", value: "prompt.clear", category: "Prompt", - disabled: true, + hidden: true, onSelect: (dialog) => { input.extmarks.clear() input.clear() @@ -167,9 +167,9 @@ export function Prompt(props: PromptProps) { { title: "Submit prompt", value: "prompt.submit", - disabled: true, keybind: "input_submit", category: "Prompt", + hidden: true, onSelect: (dialog) => { if (!input.focused) return submit() @@ -179,9 +179,9 @@ export function Prompt(props: PromptProps) { { title: "Paste", value: "prompt.paste", - disabled: true, keybind: "input_paste", category: "Prompt", + hidden: true, onSelect: async () => { const content = await Clipboard.read() if (content?.mime.startsWith("image/")) { @@ -197,8 +197,9 @@ export function Prompt(props: PromptProps) { title: "Interrupt session", value: "session.interrupt", keybind: "session_interrupt", - disabled: status().type === "idle", category: "Session", + hidden: true, + enabled: status().type !== "idle", onSelect: (dialog) => { if (autocomplete.visible) return if (!input.focused) return @@ -229,7 +230,10 @@ export function Prompt(props: PromptProps) { category: "Session", keybind: "editor_open", value: "prompt.editor", - onSelect: async (dialog, trigger) => { + slash: { + name: "editor", + }, + onSelect: async (dialog) => { dialog.clear() // replace summarized text parts with the actual text @@ -242,7 +246,7 @@ export function Prompt(props: PromptProps) { const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text") - const value = trigger === "prompt" ? "" : text + const value = text const content = await Editor.open({ value, renderer }) if (!content) return @@ -432,7 +436,7 @@ export function Prompt(props: PromptProps) { title: "Stash prompt", value: "prompt.stash", category: "Prompt", - disabled: !store.prompt.input, + enabled: !!store.prompt.input, onSelect: (dialog) => { if (!store.prompt.input) return stash.push({ @@ -450,7 +454,7 @@ export function Prompt(props: PromptProps) { title: "Stash pop", value: "prompt.stash.pop", category: "Prompt", - disabled: stash.list().length === 0, + enabled: stash.list().length > 0, onSelect: (dialog) => { const entry = stash.pop() if (entry) { @@ -466,7 +470,7 @@ export function Prompt(props: PromptProps) { title: "Stash list", value: "prompt.stash.list", category: "Prompt", - disabled: stash.list().length === 0, + enabled: stash.list().length > 0, onSelect: (dialog) => { dialog.replace(() => ( [ - ...(sync.data.config.share !== "disabled" - ? [ - { - title: "Share session", - value: "session.share", - suggested: route.type === "session", - keybind: "session_share" as const, - disabled: !!session()?.share?.url, - category: "Session", - onSelect: async (dialog: any) => { - await sdk.client.session - .share({ - sessionID: route.sessionID, - }) - .then((res) => - Clipboard.copy(res.data!.share!.url).catch(() => - toast.show({ message: "Failed to copy URL to clipboard", variant: "error" }), - ), - ) - .then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" })) - .catch(() => toast.show({ message: "Failed to share session", variant: "error" })) - dialog.clear() - }, - }, - ] - : []), + { + title: "Share session", + value: "session.share", + suggested: route.type === "session", + keybind: "session_share", + category: "Session", + enabled: sync.data.config.share !== "disabled" && !session()?.share?.url, + slash: { + name: "share", + }, + onSelect: async (dialog) => { + await sdk.client.session + .share({ + sessionID: route.sessionID, + }) + .then((res) => + Clipboard.copy(res.data!.share!.url).catch(() => + toast.show({ message: "Failed to copy URL to clipboard", variant: "error" }), + ), + ) + .then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" })) + .catch(() => toast.show({ message: "Failed to share session", variant: "error" })) + dialog.clear() + }, + }, { title: "Rename session", value: "session.rename", keybind: "session_rename", category: "Session", + slash: { + name: "rename", + }, onSelect: (dialog) => { dialog.replace(() => ) }, @@ -335,6 +337,9 @@ export function Session() { value: "session.timeline", keybind: "session_timeline", category: "Session", + slash: { + name: "timeline", + }, onSelect: (dialog) => { dialog.replace(() => ( { dialog.replace(() => ( { const selectedModel = local.model.current() if (!selectedModel) { @@ -396,8 +408,11 @@ export function Session() { title: "Unshare session", value: "session.unshare", keybind: "session_unshare", - disabled: !session()?.share?.url, category: "Session", + enabled: !!session()?.share?.url, + slash: { + name: "unshare", + }, onSelect: async (dialog) => { await sdk.client.session .unshare({ @@ -413,6 +428,9 @@ export function Session() { value: "session.undo", keybind: "messages_undo", category: "Session", + slash: { + name: "undo", + }, onSelect: async (dialog) => { const status = sync.data.session_status?.[route.sessionID] if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {}) @@ -447,8 +465,11 @@ export function Session() { title: "Redo", value: "session.redo", keybind: "messages_redo", - disabled: !session()?.revert?.messageID, category: "Session", + enabled: !!session()?.revert?.messageID, + slash: { + name: "redo", + }, onSelect: (dialog) => { dialog.clear() const messageID = session()?.revert?.messageID @@ -495,6 +516,10 @@ export function Session() { title: showTimestamps() ? "Hide timestamps" : "Show timestamps", value: "session.toggle.timestamps", category: "Session", + slash: { + name: "timestamps", + aliases: ["toggle-timestamps"], + }, onSelect: (dialog) => { setTimestamps((prev) => (prev === "show" ? "hide" : "show")) dialog.clear() @@ -504,6 +529,10 @@ export function Session() { title: showThinking() ? "Hide thinking" : "Show thinking", value: "session.toggle.thinking", category: "Session", + slash: { + name: "thinking", + aliases: ["toggle-thinking"], + }, onSelect: (dialog) => { setShowThinking((prev) => !prev) dialog.clear() @@ -513,6 +542,9 @@ export function Session() { title: "Toggle diff wrapping", value: "session.toggle.diffwrap", category: "Session", + slash: { + name: "diffwrap", + }, onSelect: (dialog) => { setDiffWrapMode((prev) => (prev === "word" ? "none" : "word")) dialog.clear() @@ -552,7 +584,7 @@ export function Session() { value: "session.page.up", keybind: "messages_page_up", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { scroll.scrollBy(-scroll.height / 2) dialog.clear() @@ -563,7 +595,7 @@ export function Session() { value: "session.page.down", keybind: "messages_page_down", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { scroll.scrollBy(scroll.height / 2) dialog.clear() @@ -574,7 +606,7 @@ export function Session() { value: "session.half.page.up", keybind: "messages_half_page_up", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { scroll.scrollBy(-scroll.height / 4) dialog.clear() @@ -585,7 +617,7 @@ export function Session() { value: "session.half.page.down", keybind: "messages_half_page_down", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { scroll.scrollBy(scroll.height / 4) dialog.clear() @@ -596,7 +628,7 @@ export function Session() { value: "session.first", keybind: "messages_first", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { scroll.scrollTo(0) dialog.clear() @@ -607,7 +639,7 @@ export function Session() { value: "session.last", keybind: "messages_last", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { scroll.scrollTo(scroll.scrollHeight) dialog.clear() @@ -618,6 +650,7 @@ export function Session() { value: "session.messages_last_user", keybind: "messages_last_user", category: "Session", + hidden: true, onSelect: () => { const messages = sync.data.message[route.sessionID] if (!messages || !messages.length) return @@ -649,7 +682,7 @@ export function Session() { value: "session.message.next", keybind: "messages_next", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => scrollToMessage("next", dialog), }, { @@ -657,7 +690,7 @@ export function Session() { value: "session.message.previous", keybind: "messages_previous", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => scrollToMessage("prev", dialog), }, { @@ -706,8 +739,10 @@ export function Session() { { title: "Copy session transcript", value: "session.copy", - keybind: "session_copy", category: "Session", + slash: { + name: "copy", + }, onSelect: async (dialog) => { try { const sessionData = session() @@ -735,6 +770,9 @@ export function Session() { value: "session.export", keybind: "session_export", category: "Session", + slash: { + name: "export", + }, onSelect: async (dialog) => { try { const sessionData = session() @@ -793,7 +831,7 @@ export function Session() { value: "session.child.next", keybind: "session_child_cycle", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { moveChild(1) dialog.clear() @@ -804,7 +842,7 @@ export function Session() { value: "session.child.previous", keybind: "session_child_cycle_reverse", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { moveChild(-1) dialog.clear() @@ -815,7 +853,7 @@ export function Session() { value: "session.parent", keybind: "session_parent", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { const parentID = session()?.parentID if (parentID) { diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index f1cdaaa5292..5c37a493dfa 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -38,7 +38,7 @@ export interface DialogSelectOption { disabled?: boolean bg?: RGBA gutter?: JSX.Element - onSelect?: (ctx: DialogContext, trigger?: "prompt") => void + onSelect?: (ctx: DialogContext) => void } export type DialogSelectRef = { From 052f887a9a7aaf79d9f1a560f9b686d59faa8348 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 17 Jan 2026 20:59:42 -0600 Subject: [PATCH 074/104] core: prevent env variables in config from being replaced with actual values When opencode.json was missing a $schema, the config loader would add it and write the file back - but with env variables like {env:API_KEY} replaced with their actual secret values. This made it impossible to safely commit opencode.json to version control. Now the original config text is preserved when adding $schema, keeping variable placeholders intact. --- packages/opencode/src/config/config.ts | 5 ++- packages/opencode/test/config/config.test.ts | 38 ++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 1574c644d32..5a2e086bff5 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1115,6 +1115,7 @@ export namespace Config { } async function load(text: string, configFilepath: string) { + const original = text text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => { return process.env[varName] || "" }) @@ -1184,7 +1185,9 @@ export namespace Config { if (parsed.success) { if (!parsed.data.$schema) { parsed.data.$schema = "https://opencode.ai/config.json" - await Bun.write(configFilepath, JSON.stringify(parsed.data, null, 2)).catch(() => {}) + // Write the $schema to the original text to preserve variables like {env:VAR} + const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",') + await Bun.write(configFilepath, updated).catch(() => {}) } const data = parsed.data if (data.plugin) { diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 86cadca5d81..0463d29d7c5 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -127,6 +127,44 @@ test("handles environment variable substitution", async () => { } }) +test("preserves env variables when adding $schema to config", async () => { + const originalEnv = process.env["PRESERVE_VAR"] + process.env["PRESERVE_VAR"] = "secret_value" + + try { + await using tmp = await tmpdir({ + init: async (dir) => { + // Config without $schema - should trigger auto-add + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + theme: "{env:PRESERVE_VAR}", + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.theme).toBe("secret_value") + + // Read the file to verify the env variable was preserved + const content = await Bun.file(path.join(tmp.path, "opencode.json")).text() + expect(content).toContain("{env:PRESERVE_VAR}") + expect(content).not.toContain("secret_value") + expect(content).toContain("$schema") + }, + }) + } finally { + if (originalEnv !== undefined) { + process.env["PRESERVE_VAR"] = originalEnv + } else { + delete process.env["PRESERVE_VAR"] + } + } +}) + test("handles file inclusion substitution", async () => { await using tmp = await tmpdir({ init: async (dir) => { From bfb8c531c22c0101d7c906c9d542b118c5a0aae0 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Sat, 17 Jan 2026 19:54:26 -0800 Subject: [PATCH 075/104] feat: bind vim-style line-by-line scrolling (#8980) Co-authored-by: Aiden Cline --- packages/opencode/src/cli/cmd/tui/event.ts | 2 ++ .../src/cli/cmd/tui/routes/session/index.tsx | 22 +++++++++++++++++++ packages/opencode/src/config/config.ts | 10 +++++++-- packages/opencode/src/server/routes/tui.ts | 2 ++ packages/sdk/js/src/gen/types.gen.ts | 8 +++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 8 +++++++ packages/sdk/openapi.json | 14 ++++++++++-- packages/web/src/content/docs/keybinds.mdx | 6 +++-- 8 files changed, 66 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/event.ts b/packages/opencode/src/cli/cmd/tui/event.ts index 7c75523c136..9466ae54f2d 100644 --- a/packages/opencode/src/cli/cmd/tui/event.ts +++ b/packages/opencode/src/cli/cmd/tui/event.ts @@ -16,6 +16,8 @@ export const TuiEvent = { "session.compact", "session.page.up", "session.page.down", + "session.line.up", + "session.line.down", "session.half.page.up", "session.half.page.down", "session.first", diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 196cd3ba1a5..1842a955d2a 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -601,6 +601,28 @@ export function Session() { dialog.clear() }, }, + { + title: "Line up", + value: "session.line.up", + keybind: "messages_line_up", + category: "Session", + disabled: true, + onSelect: (dialog) => { + scroll.scrollBy(-1) + dialog.clear() + }, + }, + { + title: "Line down", + value: "session.line.down", + keybind: "messages_line_down", + category: "Session", + disabled: true, + onSelect: (dialog) => { + scroll.scrollBy(1) + dialog.clear() + }, + }, { title: "Half page up", value: "session.half.page.up", diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 5a2e086bff5..ddb3af4b0a8 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -651,8 +651,14 @@ export namespace Config { session_unshare: z.string().optional().default("none").describe("Unshare current session"), session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"), session_compact: z.string().optional().default("c").describe("Compact the session"), - messages_page_up: z.string().optional().default("pageup").describe("Scroll messages up by one page"), - messages_page_down: z.string().optional().default("pagedown").describe("Scroll messages down by one page"), + messages_page_up: z.string().optional().default("pageup,ctrl+alt+b").describe("Scroll messages up by one page"), + messages_page_down: z + .string() + .optional() + .default("pagedown,ctrl+alt+f") + .describe("Scroll messages down by one page"), + messages_line_up: z.string().optional().default("ctrl+alt+y").describe("Scroll messages up by one line"), + messages_line_down: z.string().optional().default("ctrl+alt+e").describe("Scroll messages down by one line"), messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"), messages_half_page_down: z .string() diff --git a/packages/opencode/src/server/routes/tui.ts b/packages/opencode/src/server/routes/tui.ts index 0577429dd74..8650a0cccf7 100644 --- a/packages/opencode/src/server/routes/tui.ts +++ b/packages/opencode/src/server/routes/tui.ts @@ -275,6 +275,8 @@ export const TuiRoutes = lazy(() => session_compact: "session.compact", messages_page_up: "session.page.up", messages_page_down: "session.page.down", + messages_line_up: "session.line.up", + messages_line_down: "session.line.down", messages_half_page_up: "session.half.page.up", messages_half_page_down: "session.half.page.down", messages_first: "session.first", diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 32f33f66219..ca13e5e93cf 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -842,6 +842,14 @@ export type KeybindsConfig = { * Scroll messages down by one page */ messages_page_down?: string + /** + * Scroll messages up by one line + */ + messages_line_up?: string + /** + * Scroll messages down by one line + */ + messages_line_down?: string /** * Scroll messages up by half page */ diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e47c4f5f7f1..40bbf3feb6f 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1019,6 +1019,14 @@ export type KeybindsConfig = { * Scroll messages down by one page */ messages_page_down?: string + /** + * Scroll messages up by one line + */ + messages_line_up?: string + /** + * Scroll messages down by one line + */ + messages_line_down?: string /** * Scroll messages up by half page */ diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 0dc174c1b0a..6a3f5b2f41e 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -8282,12 +8282,22 @@ }, "messages_page_up": { "description": "Scroll messages up by one page", - "default": "pageup", + "default": "pageup,ctrl+alt+b", "type": "string" }, "messages_page_down": { "description": "Scroll messages down by one page", - "default": "pagedown", + "default": "pagedown,ctrl+alt+f", + "type": "string" + }, + "messages_line_up": { + "description": "Scroll messages up by one line", + "default": "ctrl+alt+y", + "type": "string" + }, + "messages_line_down": { + "description": "Scroll messages down by one line", + "default": "ctrl+alt+e", "type": "string" }, "messages_half_page_up": { diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 267d194c099..51508a4f864 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -31,8 +31,10 @@ OpenCode has a list of keybinds that you can customize through the OpenCode conf "session_child_cycle": "right", "session_child_cycle_reverse": "left", "session_parent": "up", - "messages_page_up": "pageup", - "messages_page_down": "pagedown", + "messages_page_up": "pageup,ctrl+alt+b", + "messages_page_down": "pagedown,ctrl+alt+f", + "messages_line_up": "ctrl+alt+y", + "messages_line_down": "ctrl+alt+e", "messages_half_page_up": "ctrl+alt+u", "messages_half_page_down": "ctrl+alt+d", "messages_first": "ctrl+g,home", From 073f9d99b58339e15e927ff4372d27d0f8ade5f2 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 18 Jan 2026 03:55:03 +0000 Subject: [PATCH 076/104] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 2 ++ packages/sdk/openapi.json | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 40bbf3feb6f..912001c82cd 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -651,6 +651,8 @@ export type EventTuiCommandExecute = { | "session.compact" | "session.page.up" | "session.page.down" + | "session.line.up" + | "session.line.down" | "session.half.page.up" | "session.half.page.down" | "session.first" diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 6a3f5b2f41e..f0af6448cca 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7411,6 +7411,8 @@ "session.compact", "session.page.up", "session.page.down", + "session.line.up", + "session.line.down", "session.half.page.up", "session.half.page.down", "session.first", From 10433cb45b6ed932368fb147032d671eaed0d273 Mon Sep 17 00:00:00 2001 From: Patrick Schiel Date: Sun, 18 Jan 2026 07:30:45 +0100 Subject: [PATCH 077/104] fix(windows): fix jdtls download on Windows (#9195) --- packages/opencode/src/lsp/server.ts | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 24da77edcfe..e7efd99dcbd 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -1157,10 +1157,24 @@ export namespace LSPServer { await fs.mkdir(distPath, { recursive: true }) const releaseURL = "https://www.eclipse.org/downloads/download.php?file=/jdtls/snapshots/jdt-language-server-latest.tar.gz" - const archivePath = path.join(distPath, "release.tar.gz") - await $`curl -L -o '${archivePath}' '${releaseURL}'`.quiet().nothrow() - await $`tar -xzf ${archivePath}`.cwd(distPath).quiet().nothrow() - await fs.rm(archivePath, { force: true }) + const archiveName = "release.tar.gz" + + log.info("Downloading JDTLS archive", { url: releaseURL, dest: distPath }) + const curlResult = await $`curl -L -o ${archiveName} '${releaseURL}'`.cwd(distPath).quiet().nothrow() + if (curlResult.exitCode !== 0) { + log.error("Failed to download JDTLS", { exitCode: curlResult.exitCode, stderr: curlResult.stderr.toString() }) + return + } + + log.info("Extracting JDTLS archive") + const tarResult = await $`tar -xzf ${archiveName}`.cwd(distPath).quiet().nothrow() + if (tarResult.exitCode !== 0) { + log.error("Failed to extract JDTLS", { exitCode: tarResult.exitCode, stderr: tarResult.stderr.toString() }) + return + } + + await fs.rm(path.join(distPath, archiveName), { force: true }) + log.info("JDTLS download and extraction completed") } const jarFileName = await $`ls org.eclipse.equinox.launcher_*.jar` .cwd(launcherDir) From b7ad6bd83922e2259a467fe59f27806af8060629 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sat, 17 Jan 2026 22:35:09 -0800 Subject: [PATCH 078/104] feat: apply_patch tool for openai models (#9127) --- packages/opencode/src/cli/cmd/debug/agent.ts | 4 +- .../src/cli/cmd/tui/routes/session/index.tsx | 80 ++- packages/opencode/src/patch/index.ts | 81 ++- .../src/server/routes/experimental.ts | 4 +- packages/opencode/src/session/prompt.ts | 5 +- .../opencode/src/session/prompt/codex.txt | 1 + packages/opencode/src/tool/apply_patch.ts | 277 ++++++++++ packages/opencode/src/tool/apply_patch.txt | 1 + packages/opencode/src/tool/batch.ts | 2 +- packages/opencode/src/tool/patch.ts | 201 ------- packages/opencode/src/tool/patch.txt | 1 - packages/opencode/src/tool/registry.ts | 19 +- .../opencode/test/tool/apply_patch.test.ts | 515 ++++++++++++++++++ packages/opencode/test/tool/patch.test.ts | 261 --------- packages/ui/src/components/message-part.css | 72 +++ packages/ui/src/components/message-part.tsx | 94 ++++ 16 files changed, 1122 insertions(+), 496 deletions(-) create mode 100644 packages/opencode/src/tool/apply_patch.ts create mode 100644 packages/opencode/src/tool/apply_patch.txt delete mode 100644 packages/opencode/src/tool/patch.ts delete mode 100644 packages/opencode/src/tool/patch.txt create mode 100644 packages/opencode/test/tool/apply_patch.test.ts delete mode 100644 packages/opencode/test/tool/patch.test.ts diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index ef6b0c4fc92..d1236ff40bc 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -70,8 +70,8 @@ export const AgentCommand = cmd({ }) async function getAvailableTools(agent: Agent.Info) { - const providerID = agent.model?.providerID ?? (await Provider.defaultModel()).providerID - return ToolRegistry.tools(providerID, agent) + const model = agent.model ?? (await Provider.defaultModel()) + return ToolRegistry.tools(model, agent) } async function resolveTools(agent: Agent.Info, availableTools: Awaited>) { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 1842a955d2a..1294ab849e9 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -39,7 +39,7 @@ import { TodoWriteTool } from "@/tool/todo" import type { GrepTool } from "@/tool/grep" import type { ListTool } from "@/tool/ls" import type { EditTool } from "@/tool/edit" -import type { PatchTool } from "@/tool/patch" +import type { ApplyPatchTool } from "@/tool/apply_patch" import type { WebFetchTool } from "@/tool/webfetch" import type { TaskTool } from "@/tool/task" import type { QuestionTool } from "@/tool/question" @@ -1445,8 +1445,8 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess - - + + @@ -1895,20 +1895,74 @@ function Edit(props: ToolProps) { ) } -function Patch(props: ToolProps) { - const { theme } = useTheme() +function ApplyPatch(props: ToolProps) { + const ctx = use() + const { theme, syntax } = useTheme() + + const files = createMemo(() => props.metadata.files ?? []) + + const view = createMemo(() => { + const diffStyle = ctx.sync.data.config.tui?.diff_style + if (diffStyle === "stacked") return "unified" + return ctx.width > 120 ? "split" : "unified" + }) + + function Diff(p: { diff: string; filePath: string }) { + return ( + + + + ) + } + + function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) { + if (file.type === "delete") return "# Deleted " + file.relativePath + if (file.type === "add") return "# Created " + file.relativePath + if (file.type === "move") return "# Moved " + normalizePath(file.filePath) + " → " + file.relativePath + return "← Patched " + file.relativePath + } + return ( - - - - {props.output?.trim()} - - + 0}> + + {(file) => ( + + + -{file.deletions} line{file.deletions !== 1 ? "s" : ""} + + } + > + + + + )} + - - Patch + + apply_patch diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts index 91d52065f6f..888a4d94b89 100644 --- a/packages/opencode/src/patch/index.ts +++ b/packages/opencode/src/patch/index.ts @@ -177,8 +177,18 @@ export namespace Patch { return { content, nextIdx: i } } + function stripHeredoc(input: string): string { + // Match heredoc patterns like: cat <<'EOF'\n...\nEOF or < 0 && pattern[pattern.length - 1] === "") { @@ -371,7 +381,7 @@ export namespace Patch { if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") { newSlice = newSlice.slice(0, -1) } - found = seekSequence(originalLines, pattern, lineIndex) + found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file) } if (found !== -1) { @@ -407,28 +417,75 @@ export namespace Patch { return result } - function seekSequence(lines: string[], pattern: string[], startIndex: number): number { - if (pattern.length === 0) return -1 + // Normalize Unicode punctuation to ASCII equivalents (like Rust's normalize_unicode) + function normalizeUnicode(str: string): string { + return str + .replace(/[\u2018\u2019\u201A\u201B]/g, "'") // single quotes + .replace(/[\u201C\u201D\u201E\u201F]/g, '"') // double quotes + .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015]/g, "-") // dashes + .replace(/\u2026/g, "...") // ellipsis + .replace(/\u00A0/g, " ") // non-breaking space + } + + type Comparator = (a: string, b: string) => boolean + + function tryMatch(lines: string[], pattern: string[], startIndex: number, compare: Comparator, eof: boolean): number { + // If EOF anchor, try matching from end of file first + if (eof) { + const fromEnd = lines.length - pattern.length + if (fromEnd >= startIndex) { + let matches = true + for (let j = 0; j < pattern.length; j++) { + if (!compare(lines[fromEnd + j], pattern[j])) { + matches = false + break + } + } + if (matches) return fromEnd + } + } - // Simple substring search implementation + // Forward search from startIndex for (let i = startIndex; i <= lines.length - pattern.length; i++) { let matches = true - for (let j = 0; j < pattern.length; j++) { - if (lines[i + j] !== pattern[j]) { + if (!compare(lines[i + j], pattern[j])) { matches = false break } } - - if (matches) { - return i - } + if (matches) return i } return -1 } + function seekSequence(lines: string[], pattern: string[], startIndex: number, eof = false): number { + if (pattern.length === 0) return -1 + + // Pass 1: exact match + const exact = tryMatch(lines, pattern, startIndex, (a, b) => a === b, eof) + if (exact !== -1) return exact + + // Pass 2: rstrip (trim trailing whitespace) + const rstrip = tryMatch(lines, pattern, startIndex, (a, b) => a.trimEnd() === b.trimEnd(), eof) + if (rstrip !== -1) return rstrip + + // Pass 3: trim (both ends) + const trim = tryMatch(lines, pattern, startIndex, (a, b) => a.trim() === b.trim(), eof) + if (trim !== -1) return trim + + // Pass 4: normalized (Unicode punctuation to ASCII) + const normalized = tryMatch( + lines, + pattern, + startIndex, + (a, b) => normalizeUnicode(a.trim()) === normalizeUnicode(b.trim()), + eof, + ) + return normalized + } + function generateUnifiedDiff(oldContent: string, newContent: string): string { const oldLines = oldContent.split("\n") const newLines = newContent.split("\n") diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/routes/experimental.ts index c6b1d42e8e5..0fb2a5e9d2e 100644 --- a/packages/opencode/src/server/routes/experimental.ts +++ b/packages/opencode/src/server/routes/experimental.ts @@ -74,8 +74,8 @@ export const ExperimentalRoutes = lazy(() => }), ), async (c) => { - const { provider } = c.req.valid("query") - const tools = await ToolRegistry.tools(provider) + const { provider, model } = c.req.valid("query") + const tools = await ToolRegistry.tools({ providerID: provider, modelID: model }) return c.json( tools.map((t) => ({ id: t.id, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 8327698fd5f..0d3d25feb8d 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -685,7 +685,10 @@ export namespace SessionPrompt { }, }) - for (const item of await ToolRegistry.tools(input.model.providerID, input.agent)) { + for (const item of await ToolRegistry.tools( + { modelID: input.model.api.id, providerID: input.model.providerID }, + input.agent, + )) { const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters)) tools[item.id] = tool({ id: item.id as any, diff --git a/packages/opencode/src/session/prompt/codex.txt b/packages/opencode/src/session/prompt/codex.txt index d26e2e01aa7..daad8237758 100644 --- a/packages/opencode/src/session/prompt/codex.txt +++ b/packages/opencode/src/session/prompt/codex.txt @@ -5,6 +5,7 @@ You are an interactive CLI tool that helps users with software engineering tasks ## Editing constraints - Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them. - Only add comments if they are necessary to make a non-obvious block easier to understand. +- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase). ## Tool usage - Prefer specialized tools over shell for file operations: diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts new file mode 100644 index 00000000000..d070eaefa97 --- /dev/null +++ b/packages/opencode/src/tool/apply_patch.ts @@ -0,0 +1,277 @@ +import z from "zod" +import * as path from "path" +import * as fs from "fs/promises" +import { Tool } from "./tool" +import { FileTime } from "../file/time" +import { Bus } from "../bus" +import { FileWatcher } from "../file/watcher" +import { Instance } from "../project/instance" +import { Patch } from "../patch" +import { createTwoFilesPatch, diffLines } from "diff" +import { assertExternalDirectory } from "./external-directory" +import { trimDiff } from "./edit" +import { LSP } from "../lsp" +import { Filesystem } from "../util/filesystem" + +const PatchParams = z.object({ + patchText: z.string().describe("The full patch text that describes all changes to be made"), +}) + +export const ApplyPatchTool = Tool.define("apply_patch", { + description: "Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.", + parameters: PatchParams, + async execute(params, ctx) { + if (!params.patchText) { + throw new Error("patchText is required") + } + + // Parse the patch to get hunks + let hunks: Patch.Hunk[] + try { + const parseResult = Patch.parsePatch(params.patchText) + hunks = parseResult.hunks + } catch (error) { + throw new Error(`apply_patch verification failed: ${error}`) + } + + if (hunks.length === 0) { + const normalized = params.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim() + if (normalized === "*** Begin Patch\n*** End Patch") { + throw new Error("patch rejected: empty patch") + } + throw new Error("apply_patch verification failed: no hunks found") + } + + // Validate file paths and check permissions + const fileChanges: Array<{ + filePath: string + oldContent: string + newContent: string + type: "add" | "update" | "delete" | "move" + movePath?: string + diff: string + additions: number + deletions: number + }> = [] + + let totalDiff = "" + + for (const hunk of hunks) { + const filePath = path.resolve(Instance.directory, hunk.path) + await assertExternalDirectory(ctx, filePath) + + switch (hunk.type) { + case "add": { + const oldContent = "" + const newContent = + hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n` + const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent)) + + let additions = 0 + let deletions = 0 + for (const change of diffLines(oldContent, newContent)) { + if (change.added) additions += change.count || 0 + if (change.removed) deletions += change.count || 0 + } + + fileChanges.push({ + filePath, + oldContent, + newContent, + type: "add", + diff, + additions, + deletions, + }) + + totalDiff += diff + "\n" + break + } + + case "update": { + // Check if file exists for update + const stats = await fs.stat(filePath).catch(() => null) + if (!stats || stats.isDirectory()) { + throw new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`) + } + + // Read file and update time tracking (like edit tool does) + await FileTime.assert(ctx.sessionID, filePath) + const oldContent = await fs.readFile(filePath, "utf-8") + let newContent = oldContent + + // Apply the update chunks to get new content + try { + const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks) + newContent = fileUpdate.content + } catch (error) { + throw new Error(`apply_patch verification failed: ${error}`) + } + + const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent)) + + let additions = 0 + let deletions = 0 + for (const change of diffLines(oldContent, newContent)) { + if (change.added) additions += change.count || 0 + if (change.removed) deletions += change.count || 0 + } + + const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined + await assertExternalDirectory(ctx, movePath) + + fileChanges.push({ + filePath, + oldContent, + newContent, + type: hunk.move_path ? "move" : "update", + movePath, + diff, + additions, + deletions, + }) + + totalDiff += diff + "\n" + break + } + + case "delete": { + const contentToDelete = await fs.readFile(filePath, "utf-8").catch((error) => { + throw new Error(`apply_patch verification failed: ${error}`) + }) + const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, "")) + + const deletions = contentToDelete.split("\n").length + + fileChanges.push({ + filePath, + oldContent: contentToDelete, + newContent: "", + type: "delete", + diff: deleteDiff, + additions: 0, + deletions, + }) + + totalDiff += deleteDiff + "\n" + break + } + } + } + + // Check permissions if needed + await ctx.ask({ + permission: "edit", + patterns: fileChanges.map((c) => path.relative(Instance.worktree, c.filePath)), + always: ["*"], + metadata: { + diff: totalDiff, + }, + }) + + // Apply the changes + const changedFiles: string[] = [] + + for (const change of fileChanges) { + switch (change.type) { + case "add": + // Create parent directories (recursive: true is safe on existing/root dirs) + await fs.mkdir(path.dirname(change.filePath), { recursive: true }) + await fs.writeFile(change.filePath, change.newContent, "utf-8") + changedFiles.push(change.filePath) + break + + case "update": + await fs.writeFile(change.filePath, change.newContent, "utf-8") + changedFiles.push(change.filePath) + break + + case "move": + if (change.movePath) { + // Create parent directories (recursive: true is safe on existing/root dirs) + await fs.mkdir(path.dirname(change.movePath), { recursive: true }) + await fs.writeFile(change.movePath, change.newContent, "utf-8") + await fs.unlink(change.filePath) + changedFiles.push(change.movePath) + } + break + + case "delete": + await fs.unlink(change.filePath) + changedFiles.push(change.filePath) + break + } + + // Update file time tracking + FileTime.read(ctx.sessionID, change.filePath) + if (change.movePath) { + FileTime.read(ctx.sessionID, change.movePath) + } + } + + // Publish file change events + for (const filePath of changedFiles) { + await Bus.publish(FileWatcher.Event.Updated, { file: filePath, event: "change" }) + } + + // Notify LSP of file changes and collect diagnostics + for (const change of fileChanges) { + if (change.type === "delete") continue + const target = change.movePath ?? change.filePath + await LSP.touchFile(target, true) + } + const diagnostics = await LSP.diagnostics() + + // Generate output summary + const summaryLines = fileChanges.map((change) => { + if (change.type === "add") { + return `A ${path.relative(Instance.worktree, change.filePath)}` + } + if (change.type === "delete") { + return `D ${path.relative(Instance.worktree, change.filePath)}` + } + const target = change.movePath ?? change.filePath + return `M ${path.relative(Instance.worktree, target)}` + }) + let output = `Success. Updated the following files:\n${summaryLines.join("\n")}` + + // Report LSP errors for changed files + const MAX_DIAGNOSTICS_PER_FILE = 20 + for (const change of fileChanges) { + if (change.type === "delete") continue + const target = change.movePath ?? change.filePath + const normalized = Filesystem.normalizePath(target) + const issues = diagnostics[normalized] ?? [] + const errors = issues.filter((item) => item.severity === 1) + if (errors.length > 0) { + const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE) + const suffix = + errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : "" + output += `\n\nLSP errors detected in ${path.relative(Instance.worktree, target)}, please fix:\n\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n` + } + } + + // Build per-file metadata for UI rendering + const files = fileChanges.map((change) => ({ + filePath: change.filePath, + relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath), + type: change.type, + diff: change.diff, + before: change.oldContent, + after: change.newContent, + additions: change.additions, + deletions: change.deletions, + movePath: change.movePath, + })) + + return { + title: output, + metadata: { + diff: totalDiff, + files, + diagnostics, + }, + output, + } + }, +}) diff --git a/packages/opencode/src/tool/apply_patch.txt b/packages/opencode/src/tool/apply_patch.txt new file mode 100644 index 00000000000..1af0606109f --- /dev/null +++ b/packages/opencode/src/tool/apply_patch.txt @@ -0,0 +1 @@ +Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON. diff --git a/packages/opencode/src/tool/batch.ts b/packages/opencode/src/tool/batch.ts index ba1b94a3e60..8bffbd54a28 100644 --- a/packages/opencode/src/tool/batch.ts +++ b/packages/opencode/src/tool/batch.ts @@ -37,7 +37,7 @@ export const BatchTool = Tool.define("batch", async () => { const discardedCalls = params.tool_calls.slice(10) const { ToolRegistry } = await import("./registry") - const availableTools = await ToolRegistry.tools("") + const availableTools = await ToolRegistry.tools({ modelID: "", providerID: "" }) const toolMap = new Map(availableTools.map((t) => [t.id, t])) const executeCall = async (call: (typeof toolCalls)[0]) => { diff --git a/packages/opencode/src/tool/patch.ts b/packages/opencode/src/tool/patch.ts deleted file mode 100644 index 08a58bfea9c..00000000000 --- a/packages/opencode/src/tool/patch.ts +++ /dev/null @@ -1,201 +0,0 @@ -import z from "zod" -import * as path from "path" -import * as fs from "fs/promises" -import { Tool } from "./tool" -import { FileTime } from "../file/time" -import { Bus } from "../bus" -import { FileWatcher } from "../file/watcher" -import { Instance } from "../project/instance" -import { Patch } from "../patch" -import { createTwoFilesPatch } from "diff" -import { assertExternalDirectory } from "./external-directory" - -const PatchParams = z.object({ - patchText: z.string().describe("The full patch text that describes all changes to be made"), -}) - -export const PatchTool = Tool.define("patch", { - description: - "Apply a patch to modify multiple files. Supports adding, updating, and deleting files with context-aware changes.", - parameters: PatchParams, - async execute(params, ctx) { - if (!params.patchText) { - throw new Error("patchText is required") - } - - // Parse the patch to get hunks - let hunks: Patch.Hunk[] - try { - const parseResult = Patch.parsePatch(params.patchText) - hunks = parseResult.hunks - } catch (error) { - throw new Error(`Failed to parse patch: ${error}`) - } - - if (hunks.length === 0) { - throw new Error("No file changes found in patch") - } - - // Validate file paths and check permissions - const fileChanges: Array<{ - filePath: string - oldContent: string - newContent: string - type: "add" | "update" | "delete" | "move" - movePath?: string - }> = [] - - let totalDiff = "" - - for (const hunk of hunks) { - const filePath = path.resolve(Instance.directory, hunk.path) - await assertExternalDirectory(ctx, filePath) - - switch (hunk.type) { - case "add": - if (hunk.type === "add") { - const oldContent = "" - const newContent = hunk.contents - const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent) - - fileChanges.push({ - filePath, - oldContent, - newContent, - type: "add", - }) - - totalDiff += diff + "\n" - } - break - - case "update": - // Check if file exists for update - const stats = await fs.stat(filePath).catch(() => null) - if (!stats || stats.isDirectory()) { - throw new Error(`File not found or is directory: ${filePath}`) - } - - // Read file and update time tracking (like edit tool does) - await FileTime.assert(ctx.sessionID, filePath) - const oldContent = await fs.readFile(filePath, "utf-8") - let newContent = oldContent - - // Apply the update chunks to get new content - try { - const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks) - newContent = fileUpdate.content - } catch (error) { - throw new Error(`Failed to apply update to ${filePath}: ${error}`) - } - - const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent) - - const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined - await assertExternalDirectory(ctx, movePath) - - fileChanges.push({ - filePath, - oldContent, - newContent, - type: hunk.move_path ? "move" : "update", - movePath, - }) - - totalDiff += diff + "\n" - break - - case "delete": - // Check if file exists for deletion - await FileTime.assert(ctx.sessionID, filePath) - const contentToDelete = await fs.readFile(filePath, "utf-8") - const deleteDiff = createTwoFilesPatch(filePath, filePath, contentToDelete, "") - - fileChanges.push({ - filePath, - oldContent: contentToDelete, - newContent: "", - type: "delete", - }) - - totalDiff += deleteDiff + "\n" - break - } - } - - // Check permissions if needed - await ctx.ask({ - permission: "edit", - patterns: fileChanges.map((c) => path.relative(Instance.worktree, c.filePath)), - always: ["*"], - metadata: { - diff: totalDiff, - }, - }) - - // Apply the changes - const changedFiles: string[] = [] - - for (const change of fileChanges) { - switch (change.type) { - case "add": - // Create parent directories - const addDir = path.dirname(change.filePath) - if (addDir !== "." && addDir !== "/") { - await fs.mkdir(addDir, { recursive: true }) - } - await fs.writeFile(change.filePath, change.newContent, "utf-8") - changedFiles.push(change.filePath) - break - - case "update": - await fs.writeFile(change.filePath, change.newContent, "utf-8") - changedFiles.push(change.filePath) - break - - case "move": - if (change.movePath) { - // Create parent directories for destination - const moveDir = path.dirname(change.movePath) - if (moveDir !== "." && moveDir !== "/") { - await fs.mkdir(moveDir, { recursive: true }) - } - // Write to new location - await fs.writeFile(change.movePath, change.newContent, "utf-8") - // Remove original - await fs.unlink(change.filePath) - changedFiles.push(change.movePath) - } - break - - case "delete": - await fs.unlink(change.filePath) - changedFiles.push(change.filePath) - break - } - - // Update file time tracking - FileTime.read(ctx.sessionID, change.filePath) - if (change.movePath) { - FileTime.read(ctx.sessionID, change.movePath) - } - } - - // Publish file change events - for (const filePath of changedFiles) { - await Bus.publish(FileWatcher.Event.Updated, { file: filePath, event: "change" }) - } - - // Generate output summary - const relativePaths = changedFiles.map((filePath) => path.relative(Instance.worktree, filePath)) - const summary = `${fileChanges.length} files changed` - - return { - title: summary, - metadata: { - diff: totalDiff, - }, - output: `Patch applied successfully. ${summary}:\n${relativePaths.map((p) => ` ${p}`).join("\n")}`, - } - }, -}) diff --git a/packages/opencode/src/tool/patch.txt b/packages/opencode/src/tool/patch.txt deleted file mode 100644 index 88a50f6347a..00000000000 --- a/packages/opencode/src/tool/patch.txt +++ /dev/null @@ -1 +0,0 @@ -do not use diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 35e378f080b..faa5f72bcce 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -26,6 +26,7 @@ import { Log } from "@/util/log" import { LspTool } from "./lsp" import { Truncate } from "./truncation" import { PlanExitTool, PlanEnterTool } from "./plan" +import { ApplyPatchTool } from "./apply_patch" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) @@ -108,6 +109,7 @@ export namespace ToolRegistry { WebSearchTool, CodeSearchTool, SkillTool, + ApplyPatchTool, ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), ...(config.experimental?.batch_tool === true ? [BatchTool] : []), ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool, PlanEnterTool] : []), @@ -119,15 +121,28 @@ export namespace ToolRegistry { return all().then((x) => x.map((t) => t.id)) } - export async function tools(providerID: string, agent?: Agent.Info) { + export async function tools( + model: { + providerID: string + modelID: string + }, + agent?: Agent.Info, + ) { const tools = await all() const result = await Promise.all( tools .filter((t) => { // Enable websearch/codesearch for zen users OR via enable flag if (t.id === "codesearch" || t.id === "websearch") { - return providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA + return model.providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA } + + // use apply tool in same format as codex + const usePatch = + model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4") + if (t.id === "apply_patch") return usePatch + if (t.id === "edit" || t.id === "write") return !usePatch + return true }) .map(async (t) => { diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts new file mode 100644 index 00000000000..d8f05a9d911 --- /dev/null +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -0,0 +1,515 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import * as fs from "fs/promises" +import { ApplyPatchTool } from "../../src/tool/apply_patch" +import { Instance } from "../../src/project/instance" +import { FileTime } from "../../src/file/time" +import { tmpdir } from "../fixture/fixture" + +const baseCtx = { + sessionID: "test", + messageID: "", + callID: "", + agent: "build", + abort: AbortSignal.any([]), + metadata: () => {}, +} + +type AskInput = { + permission: string + patterns: string[] + always: string[] + metadata: { diff: string } +} + +type ToolCtx = typeof baseCtx & { + ask: (input: AskInput) => Promise +} + +const execute = async (params: { patchText: string }, ctx: ToolCtx) => { + const tool = await ApplyPatchTool.init() + return tool.execute(params, ctx) +} + +const makeCtx = () => { + const calls: AskInput[] = [] + const ctx: ToolCtx = { + ...baseCtx, + ask: async (input) => { + calls.push(input) + }, + } + + return { ctx, calls } +} + +describe("tool.apply_patch freeform", () => { + test("requires patchText", async () => { + const { ctx } = makeCtx() + await expect(execute({ patchText: "" }, ctx)).rejects.toThrow("patchText is required") + }) + + test("rejects invalid patch format", async () => { + const { ctx } = makeCtx() + await expect(execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow("apply_patch verification failed") + }) + + test("rejects empty patch", async () => { + const { ctx } = makeCtx() + const emptyPatch = "*** Begin Patch\n*** End Patch" + await expect(execute({ patchText: emptyPatch }, ctx)).rejects.toThrow("patch rejected: empty patch") + }) + + test("applies add/update/delete in one patch", async () => { + await using fixture = await tmpdir() + const { ctx, calls } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const modifyPath = path.join(fixture.path, "modify.txt") + const deletePath = path.join(fixture.path, "delete.txt") + await fs.writeFile(modifyPath, "line1\nline2\n", "utf-8") + await fs.writeFile(deletePath, "obsolete\n", "utf-8") + FileTime.read(ctx.sessionID, modifyPath) + FileTime.read(ctx.sessionID, deletePath) + + const patchText = + "*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Delete File: delete.txt\n*** Update File: modify.txt\n@@\n-line2\n+changed\n*** End Patch" + + const result = await execute({ patchText }, ctx) + + expect(result.title).toContain("Success. Updated the following files") + expect(result.output).toContain("Success. Updated the following files") + expect(result.metadata.diff).toContain("Index:") + expect(calls.length).toBe(1) + + const added = await fs.readFile(path.join(fixture.path, "nested", "new.txt"), "utf-8") + expect(added).toBe("created\n") + expect(await fs.readFile(modifyPath, "utf-8")).toBe("line1\nchanged\n") + await expect(fs.readFile(deletePath, "utf-8")).rejects.toThrow() + }, + }) + }) + + test("applies multiple hunks to one file", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "multi.txt") + await fs.writeFile(target, "line1\nline2\nline3\nline4\n", "utf-8") + FileTime.read(ctx.sessionID, target) + + const patchText = + "*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch" + + await execute({ patchText }, ctx) + + expect(await fs.readFile(target, "utf-8")).toBe("line1\nchanged2\nline3\nchanged4\n") + }, + }) + }) + + test("inserts lines with insert-only hunk", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "insert_only.txt") + await fs.writeFile(target, "alpha\nomega\n", "utf-8") + FileTime.read(ctx.sessionID, target) + + const patchText = "*** Begin Patch\n*** Update File: insert_only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch" + + await execute({ patchText }, ctx) + + expect(await fs.readFile(target, "utf-8")).toBe("alpha\nbeta\nomega\n") + }, + }) + }) + + test("appends trailing newline on update", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "no_newline.txt") + await fs.writeFile(target, "no newline at end", "utf-8") + FileTime.read(ctx.sessionID, target) + + const patchText = + "*** Begin Patch\n*** Update File: no_newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch" + + await execute({ patchText }, ctx) + + const contents = await fs.readFile(target, "utf-8") + expect(contents.endsWith("\n")).toBe(true) + expect(contents).toBe("first line\nsecond line\n") + }, + }) + }) + + test("moves file to a new directory", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const original = path.join(fixture.path, "old", "name.txt") + await fs.mkdir(path.dirname(original), { recursive: true }) + await fs.writeFile(original, "old content\n", "utf-8") + FileTime.read(ctx.sessionID, original) + + const patchText = + "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch" + + await execute({ patchText }, ctx) + + const moved = path.join(fixture.path, "renamed", "dir", "name.txt") + await expect(fs.readFile(original, "utf-8")).rejects.toThrow() + expect(await fs.readFile(moved, "utf-8")).toBe("new content\n") + }, + }) + }) + + test("moves file overwriting existing destination", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const original = path.join(fixture.path, "old", "name.txt") + const destination = path.join(fixture.path, "renamed", "dir", "name.txt") + await fs.mkdir(path.dirname(original), { recursive: true }) + await fs.mkdir(path.dirname(destination), { recursive: true }) + await fs.writeFile(original, "from\n", "utf-8") + await fs.writeFile(destination, "existing\n", "utf-8") + FileTime.read(ctx.sessionID, original) + + const patchText = + "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch" + + await execute({ patchText }, ctx) + + await expect(fs.readFile(original, "utf-8")).rejects.toThrow() + expect(await fs.readFile(destination, "utf-8")).toBe("new\n") + }, + }) + }) + + test("adds file overwriting existing file", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "duplicate.txt") + await fs.writeFile(target, "old content\n", "utf-8") + + const patchText = "*** Begin Patch\n*** Add File: duplicate.txt\n+new content\n*** End Patch" + + await execute({ patchText }, ctx) + expect(await fs.readFile(target, "utf-8")).toBe("new content\n") + }, + }) + }) + + test("rejects update when target file is missing", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const patchText = "*** Begin Patch\n*** Update File: missing.txt\n@@\n-nope\n+better\n*** End Patch" + + await expect(execute({ patchText }, ctx)).rejects.toThrow( + "apply_patch verification failed: Failed to read file to update", + ) + }, + }) + }) + + test("rejects delete when file is missing", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const patchText = "*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch" + + await expect(execute({ patchText }, ctx)).rejects.toThrow() + }, + }) + }) + + test("rejects delete when target is a directory", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const dirPath = path.join(fixture.path, "dir") + await fs.mkdir(dirPath) + + const patchText = "*** Begin Patch\n*** Delete File: dir\n*** End Patch" + + await expect(execute({ patchText }, ctx)).rejects.toThrow() + }, + }) + }) + + test("rejects invalid hunk header", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const patchText = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch" + + await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed") + }, + }) + }) + + test("rejects update with missing context", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "modify.txt") + await fs.writeFile(target, "line1\nline2\n", "utf-8") + FileTime.read(ctx.sessionID, target) + + const patchText = "*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch" + + await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed") + expect(await fs.readFile(target, "utf-8")).toBe("line1\nline2\n") + }, + }) + }) + + test("verification failure leaves no side effects", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const patchText = + "*** Begin Patch\n*** Add File: created.txt\n+hello\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch" + + await expect(execute({ patchText }, ctx)).rejects.toThrow() + + const createdPath = path.join(fixture.path, "created.txt") + await expect(fs.readFile(createdPath, "utf-8")).rejects.toThrow() + }, + }) + }) + + test("supports end of file anchor", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "tail.txt") + await fs.writeFile(target, "alpha\nlast\n", "utf-8") + FileTime.read(ctx.sessionID, target) + + const patchText = "*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch" + + await execute({ patchText }, ctx) + expect(await fs.readFile(target, "utf-8")).toBe("alpha\nend\n") + }, + }) + }) + + test("rejects missing second chunk context", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "two_chunks.txt") + await fs.writeFile(target, "a\nb\nc\nd\n", "utf-8") + FileTime.read(ctx.sessionID, target) + + const patchText = "*** Begin Patch\n*** Update File: two_chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch" + + await expect(execute({ patchText }, ctx)).rejects.toThrow() + expect(await fs.readFile(target, "utf-8")).toBe("a\nb\nc\nd\n") + }, + }) + }) + + test("disambiguates change context with @@ header", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "multi_ctx.txt") + await fs.writeFile(target, "fn a\nx=10\ny=2\nfn b\nx=10\ny=20\n", "utf-8") + FileTime.read(ctx.sessionID, target) + + const patchText = "*** Begin Patch\n*** Update File: multi_ctx.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch" + + await execute({ patchText }, ctx) + expect(await fs.readFile(target, "utf-8")).toBe("fn a\nx=10\ny=2\nfn b\nx=11\ny=20\n") + }, + }) + }) + + test("EOF anchor matches from end of file first", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "eof_anchor.txt") + // File has duplicate "marker" lines - one in middle, one at end + await fs.writeFile(target, "start\nmarker\nmiddle\nmarker\nend\n", "utf-8") + FileTime.read(ctx.sessionID, target) + + // With EOF anchor, should match the LAST "marker" line, not the first + const patchText = + "*** Begin Patch\n*** Update File: eof_anchor.txt\n@@\n-marker\n-end\n+marker-changed\n+end\n*** End of File\n*** End Patch" + + await execute({ patchText }, ctx) + // First marker unchanged, second marker changed + expect(await fs.readFile(target, "utf-8")).toBe("start\nmarker\nmiddle\nmarker-changed\nend\n") + }, + }) + }) + + test("parses heredoc-wrapped patch", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const patchText = `cat <<'EOF' +*** Begin Patch +*** Add File: heredoc_test.txt ++heredoc content +*** End Patch +EOF` + + await execute({ patchText }, ctx) + const content = await fs.readFile(path.join(fixture.path, "heredoc_test.txt"), "utf-8") + expect(content).toBe("heredoc content\n") + }, + }) + }) + + test("parses heredoc-wrapped patch without cat", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const patchText = `< { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "trailing_ws.txt") + // File has trailing spaces on some lines + await fs.writeFile(target, "line1 \nline2\nline3 \n", "utf-8") + FileTime.read(ctx.sessionID, target) + + // Patch doesn't have trailing spaces - should still match via rstrip pass + const patchText = "*** Begin Patch\n*** Update File: trailing_ws.txt\n@@\n-line2\n+changed\n*** End Patch" + + await execute({ patchText }, ctx) + expect(await fs.readFile(target, "utf-8")).toBe("line1 \nchanged\nline3 \n") + }, + }) + }) + + test("matches with leading whitespace differences", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "leading_ws.txt") + // File has leading spaces + await fs.writeFile(target, " line1\nline2\n line3\n", "utf-8") + FileTime.read(ctx.sessionID, target) + + // Patch without leading spaces - should match via trim pass + const patchText = "*** Begin Patch\n*** Update File: leading_ws.txt\n@@\n-line2\n+changed\n*** End Patch" + + await execute({ patchText }, ctx) + expect(await fs.readFile(target, "utf-8")).toBe(" line1\nchanged\n line3\n") + }, + }) + }) + + test("matches with Unicode punctuation differences", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "unicode.txt") + // File has fancy Unicode quotes (U+201C, U+201D) and em-dash (U+2014) + const leftQuote = "\u201C" + const rightQuote = "\u201D" + const emDash = "\u2014" + await fs.writeFile(target, `He said ${leftQuote}hello${rightQuote}\nsome${emDash}dash\nend\n`, "utf-8") + FileTime.read(ctx.sessionID, target) + + // Patch uses ASCII equivalents - should match via normalized pass + // The replacement uses ASCII quotes from the patch (not preserving Unicode) + const patchText = + '*** Begin Patch\n*** Update File: unicode.txt\n@@\n-He said "hello"\n+He said "hi"\n*** End Patch' + + await execute({ patchText }, ctx) + // Result has ASCII quotes because that's what the patch specifies + expect(await fs.readFile(target, "utf-8")).toBe(`He said "hi"\nsome${emDash}dash\nend\n`) + }, + }) + }) +}) diff --git a/packages/opencode/test/tool/patch.test.ts b/packages/opencode/test/tool/patch.test.ts deleted file mode 100644 index 3d3ec574e60..00000000000 --- a/packages/opencode/test/tool/patch.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { describe, expect, test } from "bun:test" -import path from "path" -import { PatchTool } from "../../src/tool/patch" -import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" -import { PermissionNext } from "../../src/permission/next" -import * as fs from "fs/promises" - -const ctx = { - sessionID: "test", - messageID: "", - callID: "", - agent: "build", - abort: AbortSignal.any([]), - metadata: () => {}, - ask: async () => {}, -} - -const patchTool = await PatchTool.init() - -describe("tool.patch", () => { - test("should validate required parameters", async () => { - await Instance.provide({ - directory: "/tmp", - fn: async () => { - expect(patchTool.execute({ patchText: "" }, ctx)).rejects.toThrow("patchText is required") - }, - }) - }) - - test("should validate patch format", async () => { - await Instance.provide({ - directory: "/tmp", - fn: async () => { - expect(patchTool.execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow("Failed to parse patch") - }, - }) - }) - - test("should handle empty patch", async () => { - await Instance.provide({ - directory: "/tmp", - fn: async () => { - const emptyPatch = `*** Begin Patch -*** End Patch` - - expect(patchTool.execute({ patchText: emptyPatch }, ctx)).rejects.toThrow("No file changes found in patch") - }, - }) - }) - - test.skip("should ask permission for files outside working directory", async () => { - await Instance.provide({ - directory: "/tmp", - fn: async () => { - const maliciousPatch = `*** Begin Patch -*** Add File: /etc/passwd -+malicious content -*** End Patch` - patchTool.execute({ patchText: maliciousPatch }, ctx) - // TODO: this sucks - await new Promise((resolve) => setTimeout(resolve, 1000)) - const pending = await PermissionNext.list() - expect(pending.find((p) => p.sessionID === ctx.sessionID)).toBeDefined() - }, - }) - }) - - test("should handle simple add file operation", async () => { - await using fixture = await tmpdir() - - await Instance.provide({ - directory: fixture.path, - fn: async () => { - const patchText = `*** Begin Patch -*** Add File: test-file.txt -+Hello World -+This is a test file -*** End Patch` - - const result = await patchTool.execute({ patchText }, ctx) - - expect(result.title).toContain("files changed") - expect(result.metadata.diff).toBeDefined() - expect(result.output).toContain("Patch applied successfully") - - // Verify file was created - const filePath = path.join(fixture.path, "test-file.txt") - const content = await fs.readFile(filePath, "utf-8") - expect(content).toBe("Hello World\nThis is a test file") - }, - }) - }) - - test("should handle file with context update", async () => { - await using fixture = await tmpdir() - - await Instance.provide({ - directory: fixture.path, - fn: async () => { - const patchText = `*** Begin Patch -*** Add File: config.js -+const API_KEY = "test-key" -+const DEBUG = false -+const VERSION = "1.0" -*** End Patch` - - const result = await patchTool.execute({ patchText }, ctx) - - expect(result.title).toContain("files changed") - expect(result.metadata.diff).toBeDefined() - expect(result.output).toContain("Patch applied successfully") - - // Verify file was created with correct content - const filePath = path.join(fixture.path, "config.js") - const content = await fs.readFile(filePath, "utf-8") - expect(content).toBe('const API_KEY = "test-key"\nconst DEBUG = false\nconst VERSION = "1.0"') - }, - }) - }) - - test("should handle multiple file operations", async () => { - await using fixture = await tmpdir() - - await Instance.provide({ - directory: fixture.path, - fn: async () => { - const patchText = `*** Begin Patch -*** Add File: file1.txt -+Content of file 1 -*** Add File: file2.txt -+Content of file 2 -*** Add File: file3.txt -+Content of file 3 -*** End Patch` - - const result = await patchTool.execute({ patchText }, ctx) - - expect(result.title).toContain("3 files changed") - expect(result.metadata.diff).toBeDefined() - expect(result.output).toContain("Patch applied successfully") - - // Verify all files were created - for (let i = 1; i <= 3; i++) { - const filePath = path.join(fixture.path, `file${i}.txt`) - const content = await fs.readFile(filePath, "utf-8") - expect(content).toBe(`Content of file ${i}`) - } - }, - }) - }) - - test("should create parent directories when adding nested files", async () => { - await using fixture = await tmpdir() - - await Instance.provide({ - directory: fixture.path, - fn: async () => { - const patchText = `*** Begin Patch -*** Add File: deep/nested/file.txt -+Deep nested content -*** End Patch` - - const result = await patchTool.execute({ patchText }, ctx) - - expect(result.title).toContain("files changed") - expect(result.output).toContain("Patch applied successfully") - - // Verify nested file was created - const nestedPath = path.join(fixture.path, "deep", "nested", "file.txt") - const exists = await fs - .access(nestedPath) - .then(() => true) - .catch(() => false) - expect(exists).toBe(true) - - const content = await fs.readFile(nestedPath, "utf-8") - expect(content).toBe("Deep nested content") - }, - }) - }) - - test("should generate proper unified diff in metadata", async () => { - await using fixture = await tmpdir() - - await Instance.provide({ - directory: fixture.path, - fn: async () => { - // First create a file with simple content - const patchText1 = `*** Begin Patch -*** Add File: test.txt -+line 1 -+line 2 -+line 3 -*** End Patch` - - await patchTool.execute({ patchText: patchText1 }, ctx) - - // Now create an update patch - const patchText2 = `*** Begin Patch -*** Update File: test.txt -@@ - line 1 --line 2 -+line 2 updated - line 3 -*** End Patch` - - const result = await patchTool.execute({ patchText: patchText2 }, ctx) - - expect(result.metadata.diff).toBeDefined() - expect(result.metadata.diff).toContain("@@") - expect(result.metadata.diff).toContain("-line 2") - expect(result.metadata.diff).toContain("+line 2 updated") - }, - }) - }) - - test("should handle complex patch with multiple operations", async () => { - await using fixture = await tmpdir() - - await Instance.provide({ - directory: fixture.path, - fn: async () => { - const patchText = `*** Begin Patch -*** Add File: new.txt -+This is a new file -+with multiple lines -*** Add File: existing.txt -+old content -+new line -+more content -*** Add File: config.json -+{ -+ "version": "1.0", -+ "debug": true -+} -*** End Patch` - - const result = await patchTool.execute({ patchText }, ctx) - - expect(result.title).toContain("3 files changed") - expect(result.metadata.diff).toBeDefined() - expect(result.output).toContain("Patch applied successfully") - - // Verify all files were created - const newPath = path.join(fixture.path, "new.txt") - const newContent = await fs.readFile(newPath, "utf-8") - expect(newContent).toBe("This is a new file\nwith multiple lines") - - const existingPath = path.join(fixture.path, "existing.txt") - const existingContent = await fs.readFile(existingPath, "utf-8") - expect(existingContent).toBe("old content\nnew line\nmore content") - - const configPath = path.join(fixture.path, "config.json") - const configContent = await fs.readFile(configPath, "utf-8") - expect(configContent).toBe('{\n "version": "1.0",\n "debug": true\n}') - }, - }) - }) -}) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 4a249ec4f42..184565e9cb3 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -689,3 +689,75 @@ } } } + +[data-component="apply-patch-files"] { + display: flex; + flex-direction: column; +} + +[data-component="apply-patch-file"] { + display: flex; + flex-direction: column; + border-top: 1px solid var(--border-weaker-base); + + &:first-child { + border-top: 1px solid var(--border-weaker-base); + } + + [data-slot="apply-patch-file-header"] { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background-color: var(--surface-inset-base); + } + + [data-slot="apply-patch-file-action"] { + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + color: var(--text-base); + flex-shrink: 0; + + &[data-type="delete"] { + color: var(--text-critical-base); + } + + &[data-type="add"] { + color: var(--text-success-base); + } + + &[data-type="move"] { + color: var(--text-warning-base); + } + } + + [data-slot="apply-patch-file-path"] { + font-family: var(--font-family-mono); + font-size: var(--font-size-small); + color: var(--text-weak); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex-grow: 1; + } + + [data-slot="apply-patch-deletion-count"] { + font-family: var(--font-family-mono); + font-size: var(--font-size-small); + color: var(--text-critical-base); + flex-shrink: 0; + } +} + +[data-component="apply-patch-file-diff"] { + max-height: 420px; + overflow-y: auto; + scrollbar-width: none; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + } +} diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 165f46f6c50..47403786b22 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -233,6 +233,12 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { title: "Write", subtitle: input.filePath ? getFilename(input.filePath) : undefined, } + case "apply_patch": + return { + icon: "code-lines", + title: "Patch", + subtitle: input.files?.length ? `${input.files.length} file${input.files.length > 1 ? "s" : ""}` : undefined, + } case "todowrite": return { icon: "checklist", @@ -1027,6 +1033,94 @@ ToolRegistry.register({ }, }) +interface ApplyPatchFile { + filePath: string + relativePath: string + type: "add" | "update" | "delete" | "move" + diff: string + before: string + after: string + additions: number + deletions: number + movePath?: string +} + +ToolRegistry.register({ + name: "apply_patch", + render(props) { + const diffComponent = useDiffComponent() + const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[]) + + const subtitle = createMemo(() => { + const count = files().length + if (count === 0) return "" + return `${count} file${count > 1 ? "s" : ""}` + }) + + return ( + + 0}> +
+ + {(file) => ( +
+
+ + + + Deleted + + + + + Created + + + + + Moved + + + + + Patched + + + + {file.relativePath} + + + + + -{file.deletions} + +
+ +
+ +
+
+
+ )} +
+
+
+
+ ) + }, +}) + ToolRegistry.register({ name: "todowrite", render(props) { From 90f848fbc697c6ec5c52edc84bbde33c0fa6a560 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 18 Jan 2026 06:35:48 +0000 Subject: [PATCH 079/104] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 18 +++--- packages/sdk/openapi.json | 76 ++++++++++++------------- 2 files changed, 47 insertions(+), 47 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 912001c82cd..060a00e6087 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -633,6 +633,14 @@ export type EventTodoUpdated = { } } +export type EventFileWatcherUpdated = { + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" + } +} + export type EventTuiPromptAppend = { type: "tui.prompt.append" properties: { @@ -791,14 +799,6 @@ export type EventSessionError = { } } -export type EventFileWatcherUpdated = { - type: "file.watcher.updated" - properties: { - file: string - event: "add" | "change" | "unlink" - } -} - export type EventVcsBranchUpdated = { type: "vcs.branch.updated" properties: { @@ -880,6 +880,7 @@ export type Event = | EventQuestionRejected | EventSessionCompacted | EventTodoUpdated + | EventFileWatcherUpdated | EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow @@ -892,7 +893,6 @@ export type Event = | EventSessionDeleted | EventSessionDiff | EventSessionError - | EventFileWatcherUpdated | EventVcsBranchUpdated | EventPtyCreated | EventPtyUpdated diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index f0af6448cca..a307a666edc 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7370,6 +7370,41 @@ }, "required": ["type", "properties"] }, + "Event.file.watcher.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.watcher.updated" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "anyOf": [ + { + "type": "string", + "const": "add" + }, + { + "type": "string", + "const": "change" + }, + { + "type": "string", + "const": "unlink" + } + ] + } + }, + "required": ["file", "event"] + } + }, + "required": ["type", "properties"] + }, "Event.tui.prompt.append": { "type": "object", "properties": { @@ -7798,41 +7833,6 @@ }, "required": ["type", "properties"] }, - "Event.file.watcher.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.watcher.updated" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "event": { - "anyOf": [ - { - "type": "string", - "const": "add" - }, - { - "type": "string", - "const": "change" - }, - { - "type": "string", - "const": "unlink" - } - ] - } - }, - "required": ["file", "event"] - } - }, - "required": ["type", "properties"] - }, "Event.vcs.branch.updated": { "type": "object", "properties": { @@ -8054,6 +8054,9 @@ { "$ref": "#/components/schemas/Event.todo.updated" }, + { + "$ref": "#/components/schemas/Event.file.watcher.updated" + }, { "$ref": "#/components/schemas/Event.tui.prompt.append" }, @@ -8090,9 +8093,6 @@ { "$ref": "#/components/schemas/Event.session.error" }, - { - "$ref": "#/components/schemas/Event.file.watcher.updated" - }, { "$ref": "#/components/schemas/Event.vcs.branch.updated" }, From 3591372c45a8cfb3114d0f221f4ea8d51c527103 Mon Sep 17 00:00:00 2001 From: Bowen Dwelle Date: Sat, 17 Jan 2026 23:41:36 -0700 Subject: [PATCH 080/104] feat(tool): increase question header and label limits (#9201) --- packages/opencode/src/question/index.ts | 4 +- packages/opencode/test/tool/question.test.ts | 107 +++++++++++++++++++ 2 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/test/tool/question.test.ts diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index d18098a9c4f..41029ecbbdb 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -10,7 +10,7 @@ export namespace Question { export const Option = z .object({ - label: z.string().describe("Display text (1-5 words, concise)"), + label: z.string().max(30).describe("Display text (1-5 words, concise)"), description: z.string().describe("Explanation of choice"), }) .meta({ @@ -21,7 +21,7 @@ export namespace Question { export const Info = z .object({ question: z.string().describe("Complete question"), - header: z.string().max(12).describe("Very short label (max 12 chars)"), + header: z.string().max(30).describe("Very short label (max 30 chars)"), options: z.array(Option).describe("Available choices"), multiple: z.boolean().optional().describe("Allow selecting multiple choices"), custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"), diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts new file mode 100644 index 00000000000..9e3f4e25cb2 --- /dev/null +++ b/packages/opencode/test/tool/question.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test" +import { z } from "zod" +import { QuestionTool } from "../../src/tool/question" +import * as QuestionModule from "../../src/question" + +const ctx = { + sessionID: "test-session", + messageID: "test-message", + callID: "test-call", + agent: "test-agent", + abort: AbortSignal.any([]), + metadata: () => {}, + ask: async () => {}, +} + +describe("tool.question", () => { + let askSpy: any; + + beforeEach(() => { + askSpy = spyOn(QuestionModule.Question, "ask").mockImplementation(async () => { + return [] + }) + }) + + afterEach(() => { + askSpy.mockRestore() + }) + + test("should successfully execute with valid question parameters", async () => { + const tool = await QuestionTool.init() + const questions = [ + { + question: "What is your favorite color?", + header: "Color", + options: [ + { label: "Red", description: "The color of passion" }, + { label: "Blue", description: "The color of sky" }, + ], + multiple: false, + }, + ] + + askSpy.mockResolvedValueOnce([["Red"]]) + + const result = await tool.execute( + { questions }, + ctx, + ) + expect(askSpy).toHaveBeenCalledTimes(1) + expect(result.title).toBe("Asked 1 question") + }) + + test("should now pass with a header longer than 12 but less than 30 chars", async () => { + const tool = await QuestionTool.init() + const questions = [ + { + question: "What is your favorite animal?", + header: "This Header is Over 12", + options: [{ label: "Dog", description: "Man's best friend" }], + }, + ] + + askSpy.mockResolvedValueOnce([["Dog"]]) + + const result = await tool.execute({ questions }, ctx) + expect(result.output).toContain(`"What is your favorite animal?"="Dog"`) + }) + + test("should throw an Error for header exceeding 30 characters", async () => { + const tool = await QuestionTool.init() + const questions = [ + { + question: "What is your favorite animal?", + header: "This Header is Definitely More Than Thirty Characters Long", + options: [{ label: "Dog", description: "Man's best friend" }], + }, + ] + try { + await tool.execute({ questions }, ctx) + // If it reaches here, the test should fail + expect(true).toBe(false) + } catch (e: any) { + expect(e).toBeInstanceOf(Error) + expect(e.cause).toBeInstanceOf(z.ZodError) + } + }) + + test("should throw an Error for label exceeding 30 characters", async () => { + const tool = await QuestionTool.init() + const questions = [ + { + question: "A question with a very long label", + header: "Long Label", + options: [{ label: "This is a very, very, very long label that will exceed the limit", description: "A description" }], + }, + ] + try { + await tool.execute({ questions }, ctx) + // If it reaches here, the test should fail + expect(true).toBe(false) + } catch (e: any) { + expect(e).toBeInstanceOf(Error) + expect(e.cause).toBeInstanceOf(z.ZodError) + } + }) +}) + From d13c0ea915dfceeda0003247f5a513df9787429d Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 18 Jan 2026 06:42:13 +0000 Subject: [PATCH 081/104] chore: generate --- packages/opencode/test/tool/question.test.ts | 16 +++++++--------- packages/sdk/js/src/v2/gen/types.gen.ts | 2 +- packages/sdk/openapi.json | 7 ++++--- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts index 9e3f4e25cb2..fa95e9612b6 100644 --- a/packages/opencode/test/tool/question.test.ts +++ b/packages/opencode/test/tool/question.test.ts @@ -14,11 +14,11 @@ const ctx = { } describe("tool.question", () => { - let askSpy: any; + let askSpy: any beforeEach(() => { askSpy = spyOn(QuestionModule.Question, "ask").mockImplementation(async () => { - return [] + return [] }) }) @@ -42,10 +42,7 @@ describe("tool.question", () => { askSpy.mockResolvedValueOnce([["Red"]]) - const result = await tool.execute( - { questions }, - ctx, - ) + const result = await tool.execute({ questions }, ctx) expect(askSpy).toHaveBeenCalledTimes(1) expect(result.title).toBe("Asked 1 question") }) @@ -59,7 +56,7 @@ describe("tool.question", () => { options: [{ label: "Dog", description: "Man's best friend" }], }, ] - + askSpy.mockResolvedValueOnce([["Dog"]]) const result = await tool.execute({ questions }, ctx) @@ -91,7 +88,9 @@ describe("tool.question", () => { { question: "A question with a very long label", header: "Long Label", - options: [{ label: "This is a very, very, very long label that will exceed the limit", description: "A description" }], + options: [ + { label: "This is a very, very, very long label that will exceed the limit", description: "A description" }, + ], }, ] try { @@ -104,4 +103,3 @@ describe("tool.question", () => { } }) }) - diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 060a00e6087..04e7144eb72 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -545,7 +545,7 @@ export type QuestionInfo = { */ question: string /** - * Very short label (max 12 chars) + * Very short label (max 30 chars) */ header: string /** diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index a307a666edc..104cedce1e5 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7163,7 +7163,8 @@ "properties": { "label": { "description": "Display text (1-5 words, concise)", - "type": "string" + "type": "string", + "maxLength": 30 }, "description": { "description": "Explanation of choice", @@ -7180,9 +7181,9 @@ "type": "string" }, "header": { - "description": "Very short label (max 12 chars)", + "description": "Very short label (max 30 chars)", "type": "string", - "maxLength": 12 + "maxLength": 30 }, "options": { "description": "Available choices", From bef1f6628118359f98d6c41e195b16e1f68794fa Mon Sep 17 00:00:00 2001 From: Noam Bressler Date: Sun, 18 Jan 2026 09:29:42 +0200 Subject: [PATCH 082/104] fix(acp): use single global event subscription and route by sessionID (#5628) Co-authored-by: noamzbr Co-authored-by: noam-v --- packages/opencode/src/acp/agent.ts | 582 ++++++++++-------- packages/opencode/src/acp/session.ts | 4 + .../test/acp/event-subscription.test.ts | 436 +++++++++++++ 3 files changed, 754 insertions(+), 268 deletions(-) create mode 100644 packages/opencode/test/acp/event-subscription.test.ts diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index f8792393c60..a077bb9fbd3 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -20,7 +20,7 @@ import { } from "@agentclientprotocol/sdk" import { Log } from "../util/log" import { ACPSessionManager } from "./session" -import type { ACPConfig, ACPSessionState } from "./types" +import type { ACPConfig } from "./types" import { Provider } from "../provider/provider" import { Agent as AgentModule } from "../agent/agent" import { Installation } from "@/installation" @@ -29,7 +29,7 @@ import { Config } from "@/config/config" import { Todo } from "@/session/todo" import { z } from "zod" import { LoadAPIKeyError } from "ai" -import type { OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2" +import type { Event, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2" import { applyPatch } from "diff" export namespace ACP { @@ -47,304 +47,354 @@ export namespace ACP { private connection: AgentSideConnection private config: ACPConfig private sdk: OpencodeClient - private sessionManager + private sessionManager: ACPSessionManager + private eventAbort = new AbortController() + private eventStarted = false + private permissionQueues = new Map>() + private permissionOptions: PermissionOption[] = [ + { optionId: "once", kind: "allow_once", name: "Allow once" }, + { optionId: "always", kind: "allow_always", name: "Always allow" }, + { optionId: "reject", kind: "reject_once", name: "Reject" }, + ] constructor(connection: AgentSideConnection, config: ACPConfig) { this.connection = connection this.config = config this.sdk = config.sdk this.sessionManager = new ACPSessionManager(this.sdk) + this.startEventSubscription() } - private setupEventSubscriptions(session: ACPSessionState) { - const sessionId = session.id - const directory = session.cwd + private startEventSubscription() { + if (this.eventStarted) return + this.eventStarted = true + this.runEventSubscription().catch((error) => { + if (this.eventAbort.signal.aborted) return + log.error("event subscription failed", { error }) + }) + } - const options: PermissionOption[] = [ - { optionId: "once", kind: "allow_once", name: "Allow once" }, - { optionId: "always", kind: "allow_always", name: "Always allow" }, - { optionId: "reject", kind: "reject_once", name: "Reject" }, - ] - this.config.sdk.event.subscribe({ directory }).then(async (events) => { + private async runEventSubscription() { + while (true) { + if (this.eventAbort.signal.aborted) return + const events = await this.sdk.global.event({ + signal: this.eventAbort.signal, + }) for await (const event of events.stream) { - switch (event.type) { - case "permission.asked": - try { - const permission = event.properties - const res = await this.connection - .requestPermission({ - sessionId, - toolCall: { - toolCallId: permission.tool?.callID ?? permission.id, - status: "pending", - title: permission.permission, - rawInput: permission.metadata, - kind: toToolKind(permission.permission), - locations: toLocations(permission.permission, permission.metadata), - }, - options, - }) - .catch(async (error) => { - log.error("failed to request permission from ACP", { - error, - permissionID: permission.id, - sessionID: permission.sessionID, - }) - await this.config.sdk.permission.reply({ - requestID: permission.id, - reply: "reject", - directory, - }) - return + if (this.eventAbort.signal.aborted) return + const payload = (event as any)?.payload + if (!payload) continue + await this.handleEvent(payload as Event).catch((error) => { + log.error("failed to handle event", { error, type: payload.type }) + }) + } + } + } + + private async handleEvent(event: Event) { + switch (event.type) { + case "permission.asked": { + const permission = event.properties + const session = this.sessionManager.tryGet(permission.sessionID) + if (!session) return + + const prev = this.permissionQueues.get(permission.sessionID) ?? Promise.resolve() + const next = prev + .then(async () => { + const directory = session.cwd + + const res = await this.connection + .requestPermission({ + sessionId: permission.sessionID, + toolCall: { + toolCallId: permission.tool?.callID ?? permission.id, + status: "pending", + title: permission.permission, + rawInput: permission.metadata, + kind: toToolKind(permission.permission), + locations: toLocations(permission.permission, permission.metadata), + }, + options: this.permissionOptions, + }) + .catch(async (error) => { + log.error("failed to request permission from ACP", { + error, + permissionID: permission.id, + sessionID: permission.sessionID, }) - if (!res) return - if (res.outcome.outcome !== "selected") { - await this.config.sdk.permission.reply({ + await this.sdk.permission.reply({ requestID: permission.id, reply: "reject", directory, }) - return - } - if (res.outcome.optionId !== "reject" && permission.permission == "edit") { - const metadata = permission.metadata || {} - const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : "" - const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : "" - - const content = await Bun.file(filepath).text() - const newContent = getNewContent(content, diff) - - if (newContent) { - this.connection.writeTextFile({ - sessionId: sessionId, - path: filepath, - content: newContent, - }) - } - } - await this.config.sdk.permission.reply({ + return undefined + }) + + if (!res) return + if (res.outcome.outcome !== "selected") { + await this.sdk.permission.reply({ requestID: permission.id, - reply: res.outcome.optionId as "once" | "always" | "reject", + reply: "reject", directory, }) - } catch (err) { - log.error("unexpected error when handling permission", { error: err }) - } finally { - break + return } - case "message.part.updated": - log.info("message part updated", { event: event.properties }) - try { - const props = event.properties - const { part } = props - - const message = await this.config.sdk.session - .message( - { - sessionID: part.sessionID, - messageID: part.messageID, - directory, + if (res.outcome.optionId !== "reject" && permission.permission == "edit") { + const metadata = permission.metadata || {} + const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : "" + const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : "" + + const content = await Bun.file(filepath).text() + const newContent = getNewContent(content, diff) + + if (newContent) { + this.connection.writeTextFile({ + sessionId: session.id, + path: filepath, + content: newContent, + }) + } + } + + await this.sdk.permission.reply({ + requestID: permission.id, + reply: res.outcome.optionId as "once" | "always" | "reject", + directory, + }) + }) + .catch((error) => { + log.error("failed to handle permission", { error, permissionID: permission.id }) + }) + .finally(() => { + if (this.permissionQueues.get(permission.sessionID) === next) { + this.permissionQueues.delete(permission.sessionID) + } + }) + this.permissionQueues.set(permission.sessionID, next) + return + } + + case "message.part.updated": { + log.info("message part updated", { event: event.properties }) + const props = event.properties + const part = props.part + const session = this.sessionManager.tryGet(part.sessionID) + if (!session) return + const sessionId = session.id + const directory = session.cwd + + const message = await this.sdk.session + .message( + { + sessionID: part.sessionID, + messageID: part.messageID, + directory, + }, + { throwOnError: true }, + ) + .then((x) => x.data) + .catch((error) => { + log.error("unexpected error when fetching message", { error }) + return undefined + }) + + if (!message || message.info.role !== "assistant") return + + if (part.type === "tool") { + switch (part.state.status) { + case "pending": + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: part.callID, + title: part.tool, + kind: toToolKind(part.tool), + status: "pending", + locations: [], + rawInput: {}, }, - { throwOnError: true }, - ) - .then((x) => x.data) - .catch((err) => { - log.error("unexpected error when fetching message", { error: err }) - return undefined }) + .catch((error) => { + log.error("failed to send tool pending to ACP", { error }) + }) + return - if (!message || message.info.role !== "assistant") return - - if (part.type === "tool") { - switch (part.state.status) { - case "pending": - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call", - toolCallId: part.callID, - title: part.tool, - kind: toToolKind(part.tool), - status: "pending", - locations: [], - rawInput: {}, - }, - }) - .catch((err) => { - log.error("failed to send tool pending to ACP", { error: err }) - }) - break - case "running": - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "in_progress", - kind: toToolKind(part.tool), - title: part.tool, - locations: toLocations(part.tool, part.state.input), - rawInput: part.state.input, - }, - }) - .catch((err) => { - log.error("failed to send tool in_progress to ACP", { error: err }) - }) - break - case "completed": - const kind = toToolKind(part.tool) - const content: ToolCallContent[] = [ - { - type: "content", - content: { - type: "text", - text: part.state.output, - }, - }, - ] - - if (kind === "edit") { - const input = part.state.input - const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" - const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" - const newText = - typeof input["newString"] === "string" - ? input["newString"] - : typeof input["content"] === "string" - ? input["content"] - : "" - content.push({ - type: "diff", - path: filePath, - oldText, - newText, - }) - } - - if (part.tool === "todowrite") { - const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output)) - if (parsedTodos.success) { - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "plan", - entries: parsedTodos.data.map((todo) => { - const status: PlanEntry["status"] = - todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"]) - return { - priority: "medium", - status, - content: todo.content, - } - }), - }, - }) - .catch((err) => { - log.error("failed to send session update for todo", { error: err }) - }) - } else { - log.error("failed to parse todo output", { error: parsedTodos.error }) - } - } - - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "completed", - kind, - content, - title: part.state.title, - rawInput: part.state.input, - rawOutput: { - output: part.state.output, - metadata: part.state.metadata, - }, - }, - }) - .catch((err) => { - log.error("failed to send tool completed to ACP", { error: err }) - }) - break - case "error": - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "failed", - kind: toToolKind(part.tool), - title: part.tool, - rawInput: part.state.input, - content: [ - { - type: "content", - content: { - type: "text", - text: part.state.error, - }, - }, - ], - rawOutput: { - error: part.state.error, - }, - }, - }) - .catch((err) => { - log.error("failed to send tool error to ACP", { error: err }) - }) - break - } - } else if (part.type === "text") { - const delta = props.delta - if (delta && part.synthetic !== true) { + case "running": + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: part.callID, + status: "in_progress", + kind: toToolKind(part.tool), + title: part.tool, + locations: toLocations(part.tool, part.state.input), + rawInput: part.state.input, + }, + }) + .catch((error) => { + log.error("failed to send tool in_progress to ACP", { error }) + }) + return + + case "completed": { + const kind = toToolKind(part.tool) + const content: ToolCallContent[] = [ + { + type: "content", + content: { + type: "text", + text: part.state.output, + }, + }, + ] + + if (kind === "edit") { + const input = part.state.input + const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" + const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" + const newText = + typeof input["newString"] === "string" + ? input["newString"] + : typeof input["content"] === "string" + ? input["content"] + : "" + content.push({ + type: "diff", + path: filePath, + oldText, + newText, + }) + } + + if (part.tool === "todowrite") { + const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output)) + if (parsedTodos.success) { await this.connection .sessionUpdate({ sessionId, update: { - sessionUpdate: "agent_message_chunk", - content: { - type: "text", - text: delta, - }, + sessionUpdate: "plan", + entries: parsedTodos.data.map((todo) => { + const status: PlanEntry["status"] = + todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"]) + return { + priority: "medium", + status, + content: todo.content, + } + }), }, }) - .catch((err) => { - log.error("failed to send text to ACP", { error: err }) + .catch((error) => { + log.error("failed to send session update for todo", { error }) }) + } else { + log.error("failed to parse todo output", { error: parsedTodos.error }) } - } else if (part.type === "reasoning") { - const delta = props.delta - if (delta) { - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "agent_thought_chunk", + } + + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: part.callID, + status: "completed", + kind, + content, + title: part.state.title, + rawInput: part.state.input, + rawOutput: { + output: part.state.output, + metadata: part.state.metadata, + }, + }, + }) + .catch((error) => { + log.error("failed to send tool completed to ACP", { error }) + }) + return + } + case "error": + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: part.callID, + status: "failed", + kind: toToolKind(part.tool), + title: part.tool, + rawInput: part.state.input, + content: [ + { + type: "content", content: { type: "text", - text: delta, + text: part.state.error, }, }, - }) - .catch((err) => { - log.error("failed to send reasoning to ACP", { error: err }) - }) - } - } - } finally { - break - } + ], + rawOutput: { + error: part.state.error, + }, + }, + }) + .catch((error) => { + log.error("failed to send tool error to ACP", { error }) + }) + return + } + } + + if (part.type === "text") { + const delta = props.delta + if (delta && part.synthetic !== true) { + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: delta, + }, + }, + }) + .catch((error) => { + log.error("failed to send text to ACP", { error }) + }) + } + return } + + if (part.type === "reasoning") { + const delta = props.delta + if (delta) { + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_thought_chunk", + content: { + type: "text", + text: delta, + }, + }, + }) + .catch((error) => { + log.error("failed to send reasoning to ACP", { error }) + }) + } + } + return } - }) + } } async initialize(params: InitializeRequest): Promise { @@ -409,8 +459,6 @@ export namespace ACP { sessionId, }) - this.setupEventSubscriptions(state) - return { sessionId, models: load.models, @@ -436,7 +484,7 @@ export namespace ACP { const model = await defaultModel(this.config, directory) // Store ACP session state - const state = await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model) + await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model) log.info("load_session", { sessionId, mcpServers: params.mcpServers.length }) @@ -446,8 +494,6 @@ export namespace ACP { sessionId, }) - this.setupEventSubscriptions(state) - // Replay session history const messages = await this.sdk.session .messages( diff --git a/packages/opencode/src/acp/session.ts b/packages/opencode/src/acp/session.ts index 70b65834705..151fa5646ba 100644 --- a/packages/opencode/src/acp/session.ts +++ b/packages/opencode/src/acp/session.ts @@ -13,6 +13,10 @@ export class ACPSessionManager { this.sdk = sdk } + tryGet(sessionId: string): ACPSessionState | undefined { + return this.sessions.get(sessionId) + } + async create(cwd: string, mcpServers: McpServer[], model?: ACPSessionState["model"]): Promise { const session = await this.sdk.session .create( diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts new file mode 100644 index 00000000000..8e139ff5973 --- /dev/null +++ b/packages/opencode/test/acp/event-subscription.test.ts @@ -0,0 +1,436 @@ +import { describe, expect, test } from "bun:test" +import { ACP } from "../../src/acp/agent" +import type { AgentSideConnection } from "@agentclientprotocol/sdk" +import type { Event } from "@opencode-ai/sdk/v2" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +type SessionUpdateParams = Parameters[0] +type RequestPermissionParams = Parameters[0] +type RequestPermissionResult = Awaited> + +type GlobalEventEnvelope = { + directory?: string + payload?: Event +} + +type EventController = { + push: (event: GlobalEventEnvelope) => void + close: () => void +} + +function createEventStream() { + const queue: GlobalEventEnvelope[] = [] + const waiters: Array<(value: GlobalEventEnvelope | undefined) => void> = [] + const state = { closed: false } + + const push = (event: GlobalEventEnvelope) => { + const waiter = waiters.shift() + if (waiter) { + waiter(event) + return + } + queue.push(event) + } + + const close = () => { + state.closed = true + for (const waiter of waiters.splice(0)) { + waiter(undefined) + } + } + + const stream = async function* (signal?: AbortSignal) { + while (true) { + if (signal?.aborted) return + const next = queue.shift() + if (next) { + yield next + continue + } + if (state.closed) return + const value = await new Promise((resolve) => { + waiters.push(resolve) + if (!signal) return + signal.addEventListener("abort", () => resolve(undefined), { once: true }) + }) + if (!value) return + yield value + } + } + + return { controller: { push, close } satisfies EventController, stream } +} + +function createFakeAgent() { + const updates = new Map() + const chunks = new Map() + const record = (sessionId: string, type: string) => { + const list = updates.get(sessionId) ?? [] + list.push(type) + updates.set(sessionId, list) + } + + const connection = { + async sessionUpdate(params: SessionUpdateParams) { + const update = params.update + const type = update?.sessionUpdate ?? "unknown" + record(params.sessionId, type) + if (update?.sessionUpdate === "agent_message_chunk") { + const content = update.content + if (content?.type !== "text") return + if (typeof content.text !== "string") return + chunks.set(params.sessionId, (chunks.get(params.sessionId) ?? "") + content.text) + } + }, + async requestPermission(_params: RequestPermissionParams): Promise { + return { outcome: { outcome: "selected", optionId: "once" } } as RequestPermissionResult + }, + } as unknown as AgentSideConnection + + const { controller, stream } = createEventStream() + const calls = { + eventSubscribe: 0, + sessionCreate: 0, + } + + const sdk = { + global: { + event: async (opts?: { signal?: AbortSignal }) => { + calls.eventSubscribe++ + return { stream: stream(opts?.signal) } + }, + }, + session: { + create: async (_params?: any) => { + calls.sessionCreate++ + return { + data: { + id: `ses_${calls.sessionCreate}`, + time: { created: new Date().toISOString() }, + }, + } + }, + get: async (_params?: any) => { + return { + data: { + id: "ses_1", + time: { created: new Date().toISOString() }, + }, + } + }, + messages: async () => { + return { data: [] } + }, + message: async () => { + return { + data: { + info: { + role: "assistant", + }, + }, + } + }, + }, + permission: { + respond: async () => { + return { data: true } + }, + }, + config: { + providers: async () => { + return { + data: { + providers: [ + { + id: "opencode", + name: "opencode", + models: { + "big-pickle": { id: "big-pickle", name: "big-pickle" }, + }, + }, + ], + }, + } + }, + }, + app: { + agents: async () => { + return { + data: [ + { + name: "build", + description: "build", + mode: "agent", + }, + ], + } + }, + }, + command: { + list: async () => { + return { data: [] } + }, + }, + mcp: { + add: async () => { + return { data: true } + }, + }, + } as any + + const agent = new ACP.Agent(connection, { + sdk, + defaultModel: { providerID: "opencode", modelID: "big-pickle" }, + } as any) + + const stop = () => { + controller.close() + ;(agent as any).eventAbort.abort() + } + + return { agent, controller, calls, updates, chunks, stop, sdk, connection } +} + +describe("acp.agent event subscription", () => { + test("routes message.part.updated by the event sessionID (no cross-session pollution)", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, controller, updates, stop } = createFakeAgent() + const cwd = "/tmp/opencode-acp-test" + + const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + const sessionB = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + + controller.push({ + directory: cwd, + payload: { + type: "message.part.updated", + properties: { + part: { + sessionID: sessionB, + messageID: "msg_1", + type: "text", + synthetic: false, + }, + delta: "hello", + }, + }, + } as any) + + await new Promise((r) => setTimeout(r, 10)) + + expect((updates.get(sessionA) ?? []).includes("agent_message_chunk")).toBe(false) + expect((updates.get(sessionB) ?? []).includes("agent_message_chunk")).toBe(true) + + stop() + }, + }) + }) + + test("keeps concurrent sessions isolated when message.part.updated events are interleaved", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, controller, chunks, stop } = createFakeAgent() + const cwd = "/tmp/opencode-acp-test" + + const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + const sessionB = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + + const tokenA = ["ALPHA_", "111", "_X"] + const tokenB = ["BETA_", "222", "_Y"] + + const push = (sessionId: string, messageID: string, delta: string) => { + controller.push({ + directory: cwd, + payload: { + type: "message.part.updated", + properties: { + part: { + sessionID: sessionId, + messageID, + type: "text", + synthetic: false, + }, + delta, + }, + }, + } as any) + } + + push(sessionA, "msg_a", tokenA[0]) + push(sessionB, "msg_b", tokenB[0]) + push(sessionA, "msg_a", tokenA[1]) + push(sessionB, "msg_b", tokenB[1]) + push(sessionA, "msg_a", tokenA[2]) + push(sessionB, "msg_b", tokenB[2]) + + await new Promise((r) => setTimeout(r, 20)) + + const a = chunks.get(sessionA) ?? "" + const b = chunks.get(sessionB) ?? "" + + expect(a).toContain(tokenA.join("")) + expect(b).toContain(tokenB.join("")) + for (const part of tokenB) expect(a).not.toContain(part) + for (const part of tokenA) expect(b).not.toContain(part) + + stop() + }, + }) + }) + + test("does not create additional event subscriptions on repeated loadSession()", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, calls, stop } = createFakeAgent() + const cwd = "/tmp/opencode-acp-test" + + const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + + await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) + await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) + await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) + await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) + + expect(calls.eventSubscribe).toBe(1) + + stop() + }, + }) + }) + + test("permission.asked events are handled and replied", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const permissionReplies: string[] = [] + const { agent, controller, stop, sdk } = createFakeAgent() + sdk.permission.reply = async (params: any) => { + permissionReplies.push(params.requestID) + return { data: true } + } + const cwd = "/tmp/opencode-acp-test" + + const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + + controller.push({ + directory: cwd, + payload: { + type: "permission.asked", + properties: { + id: "perm_1", + sessionID: sessionA, + permission: "bash", + patterns: ["*"], + metadata: {}, + always: [], + }, + }, + } as any) + + await new Promise((r) => setTimeout(r, 20)) + + expect(permissionReplies).toContain("perm_1") + + stop() + }, + }) + }) + + test("permission prompt on session A does not block message updates for session B", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const permissionReplies: string[] = [] + let resolvePermissionA: (() => void) | undefined + const permissionABlocking = new Promise((r) => { + resolvePermissionA = r + }) + + const { agent, controller, chunks, stop, sdk, connection } = createFakeAgent() + + // Make permission request for session A block until we release it + const originalRequestPermission = connection.requestPermission.bind(connection) + let permissionCalls = 0 + connection.requestPermission = async (params: RequestPermissionParams) => { + permissionCalls++ + if (params.sessionId.endsWith("1")) { + await permissionABlocking + } + return originalRequestPermission(params) + } + + sdk.permission.reply = async (params: any) => { + permissionReplies.push(params.requestID) + return { data: true } + } + + const cwd = "/tmp/opencode-acp-test" + + const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + const sessionB = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + + // Push permission.asked for session A (will block) + controller.push({ + directory: cwd, + payload: { + type: "permission.asked", + properties: { + id: "perm_a", + sessionID: sessionA, + permission: "bash", + patterns: ["*"], + metadata: {}, + always: [], + }, + }, + } as any) + + // Give time for permission handling to start + await new Promise((r) => setTimeout(r, 10)) + + // Push message for session B while A's permission is pending + controller.push({ + directory: cwd, + payload: { + type: "message.part.updated", + properties: { + part: { + sessionID: sessionB, + messageID: "msg_b", + type: "text", + synthetic: false, + }, + delta: "session_b_message", + }, + }, + } as any) + + // Wait for session B's message to be processed + await new Promise((r) => setTimeout(r, 20)) + + // Session B should have received message even though A's permission is still pending + expect(chunks.get(sessionB) ?? "").toContain("session_b_message") + expect(permissionReplies).not.toContain("perm_a") + + // Release session A's permission + resolvePermissionA!() + await new Promise((r) => setTimeout(r, 20)) + + // Now session A's permission should be replied + expect(permissionReplies).toContain("perm_a") + + stop() + }, + }) + }) +}) From ee4ea653116803fcb2af731ca0a520e5fee2c68d Mon Sep 17 00:00:00 2001 From: Noam Bressler Date: Sun, 18 Jan 2026 09:29:57 +0200 Subject: [PATCH 083/104] fix: restore persisted model/agent when loading ACP session (#7809) Co-authored-by: noam-v --- packages/opencode/src/acp/agent.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index a077bb9fbd3..5fca2725587 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -488,7 +488,7 @@ export namespace ACP { log.info("load_session", { sessionId, mcpServers: params.mcpServers.length }) - const mode = await this.loadSessionMode({ + const result = await this.loadSessionMode({ cwd: directory, mcpServers: params.mcpServers, sessionId, @@ -509,12 +509,20 @@ export namespace ACP { return undefined }) + const lastUser = messages?.findLast((m) => m.info.role === "user")?.info + if (lastUser?.role === "user") { + result.models.currentModelId = `${lastUser.model.providerID}/${lastUser.model.modelID}` + if (result.modes.availableModes.some((m) => m.id === lastUser.agent)) { + result.modes.currentModeId = lastUser.agent + } + } + for (const msg of messages ?? []) { log.debug("replay message", msg) await this.processMessage(msg) } - return mode + return result } catch (e) { const error = MessageV2.fromError(e, { providerID: this.config.defaultModel?.providerID ?? "unknown", From 0ccf9bd9acf5d861c89c39878f50fc9cd7f48735 Mon Sep 17 00:00:00 2001 From: Mani Sundararajan <10191300+itsrainingmani@users.noreply.github.com> Date: Sun, 18 Jan 2026 03:40:01 -0500 Subject: [PATCH 084/104] feat(cli): uninstall opencode installed via windows package managers (#8571) --- packages/opencode/src/cli/cmd/uninstall.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/uninstall.ts b/packages/opencode/src/cli/cmd/uninstall.ts index 62210d57586..704d3572bbb 100644 --- a/packages/opencode/src/cli/cmd/uninstall.ts +++ b/packages/opencode/src/cli/cmd/uninstall.ts @@ -133,6 +133,8 @@ async function showRemovalSummary(targets: RemovalTargets, method: Installation. bun: "bun remove -g opencode-ai", yarn: "yarn global remove opencode-ai", brew: "brew uninstall opencode", + choco: "choco uninstall opencode", + scoop: "scoop uninstall opencode", } prompts.log.info(` ✓ Package: ${cmds[method] || method}`) } @@ -182,16 +184,27 @@ async function executeUninstall(method: Installation.Method, targets: RemovalTar bun: ["bun", "remove", "-g", "opencode-ai"], yarn: ["yarn", "global", "remove", "opencode-ai"], brew: ["brew", "uninstall", "opencode"], + choco: ["choco", "uninstall", "opencode"], + scoop: ["scoop", "uninstall", "opencode"], } const cmd = cmds[method] if (cmd) { spinner.start(`Running ${cmd.join(" ")}...`) - const result = await $`${cmd}`.quiet().nothrow() + const result = + method === "choco" + ? await $`echo Y | choco uninstall opencode -y -r`.quiet().nothrow() + : await $`${cmd}`.quiet().nothrow() if (result.exitCode !== 0) { - spinner.stop(`Package manager uninstall failed`, 1) - prompts.log.warn(`You may need to run manually: ${cmd.join(" ")}`) - errors.push(`Package manager: exit code ${result.exitCode}`) + spinner.stop(`Package manager uninstall failed: exit code ${result.exitCode}`, 1) + if ( + method === "choco" && + result.stdout.toString("utf8").includes("not running from an elevated command shell") + ) { + prompts.log.warn(`You may need to run '${cmd.join(" ")}' from an elevated command shell`) + } else { + prompts.log.warn(`You may need to run manually: ${cmd.join(" ")}`) + } } else { spinner.stop("Package removed") } From 06bc4dcb06a814b501dd1190cb1a424eafb91f13 Mon Sep 17 00:00:00 2001 From: Mani Sundararajan <10191300+itsrainingmani@users.noreply.github.com> Date: Sun, 18 Jan 2026 06:12:07 -0500 Subject: [PATCH 085/104] feat(desktop): implement session unshare button (#8660) --- .../src/components/session/session-header.tsx | 180 ++++++++++++++---- packages/app/src/pages/session.tsx | 66 +++++++ 2 files changed, 212 insertions(+), 34 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 7070f0c9337..4c709feefec 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -1,15 +1,17 @@ -import { createMemo, createResource, Show } from "solid-js" +import { createEffect, createMemo, onCleanup, Show } from "solid-js" +import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" import { useParams } from "@solidjs/router" import { useLayout } from "@/context/layout" import { useCommand } from "@/context/command" // import { useServer } from "@/context/server" // import { useDialog } from "@opencode-ai/ui/context/dialog" +import { usePlatform } from "@/context/platform" import { useSync } from "@/context/sync" import { useGlobalSDK } from "@/context/global-sdk" import { getFilename } from "@opencode-ai/util/path" import { base64Decode } from "@opencode-ai/util/encode" -import { iife } from "@opencode-ai/util/iife" + import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Button } from "@opencode-ai/ui/button" @@ -26,6 +28,7 @@ export function SessionHeader() { // const server = useServer() // const dialog = useDialog() const sync = useSync() + const platform = usePlatform() const projectDirectory = createMemo(() => base64Decode(params.dir ?? "")) const project = createMemo(() => { @@ -45,6 +48,78 @@ export function SessionHeader() { const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const view = createMemo(() => layout.view(sessionKey())) + const [state, setState] = createStore({ + share: false, + unshare: false, + copied: false, + timer: undefined as number | undefined, + }) + const shareUrl = createMemo(() => currentSession()?.share?.url) + + createEffect(() => { + const url = shareUrl() + if (url) return + if (state.timer) window.clearTimeout(state.timer) + setState({ copied: false, timer: undefined }) + }) + + onCleanup(() => { + if (state.timer) window.clearTimeout(state.timer) + }) + + function shareSession() { + const session = currentSession() + if (!session || state.share) return + setState("share", true) + globalSDK.client.session + .share({ sessionID: session.id, directory: projectDirectory() }) + .catch((error) => { + console.error("Failed to share session", error) + }) + .finally(() => { + setState("share", false) + }) + } + + function unshareSession() { + const session = currentSession() + if (!session || state.unshare) return + setState("unshare", true) + globalSDK.client.session + .unshare({ sessionID: session.id, directory: projectDirectory() }) + .catch((error) => { + console.error("Failed to unshare session", error) + }) + .finally(() => { + setState("unshare", false) + }) + } + + function copyLink() { + const url = shareUrl() + if (!url) return + navigator.clipboard + .writeText(url) + .then(() => { + if (state.timer) window.clearTimeout(state.timer) + setState("copied", true) + const timer = window.setTimeout(() => { + setState("copied", false) + setState("timer", undefined) + }, 3000) + setState("timer", timer) + }) + .catch((error) => { + console.error("Failed to copy share link", error) + }) + } + + function viewShare() { + const url = shareUrl() + if (!url) return + platform.openLink(url) + } + const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center")) const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right")) @@ -159,40 +234,77 @@ export function SessionHeader() {
- - - - } - > - {iife(() => { - const [url] = createResource( - () => currentSession(), - async (session) => { - if (!session) return - let shareURL = session.share?.url - if (!shareURL) { - shareURL = await globalSDK.client.session - .share({ sessionID: session.id, directory: projectDirectory() }) - .then((r) => r.data?.share?.url) - .catch((e) => { - console.error("Failed to share session", e) - return undefined - }) +
+ + + + } + > +
+ + +
} - return shareURL - }, - { initialValue: "" }, - ) - return ( - - {(shareUrl) => } + > +
+ +
+ + +
+
- ) - })} -
+
+
+ + + + + +
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index dbdbbc7eb55..d76ff99b340 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -654,6 +654,72 @@ export default function Page() { disabled: !params.id || visibleUserMessages().length === 0, onSelect: () => dialog.show(() => ), }, + ...(sync.data.config.share !== "disabled" + ? [ + { + id: "session.share", + title: "Share session", + description: "Share this session and copy the URL to clipboard", + category: "Session", + slash: "share", + disabled: !params.id || !!info()?.share?.url, + onSelect: async () => { + if (!params.id) return + await sdk.client.session + .share({ sessionID: params.id }) + .then((res) => { + navigator.clipboard.writeText(res.data!.share!.url).catch(() => + showToast({ + title: "Failed to copy URL to clipboard", + variant: "error", + }), + ) + }) + .then(() => + showToast({ + title: "Session shared", + description: "Share URL copied to clipboard!", + variant: "success", + }), + ) + .catch(() => + showToast({ + title: "Failed to share session", + description: "An error occurred while sharing the session", + variant: "error", + }), + ) + }, + }, + { + id: "session.unshare", + title: "Unshare session", + description: "Stop sharing this session", + category: "Session", + slash: "unshare", + disabled: !params.id || !info()?.share?.url, + onSelect: async () => { + if (!params.id) return + await sdk.client.session + .unshare({ sessionID: params.id }) + .then(() => + showToast({ + title: "Session unshared", + description: "Session unshared successfully!", + variant: "success", + }), + ) + .catch(() => + showToast({ + title: "Failed to unshare session", + description: "An error occurred while unsharing the session", + variant: "error", + }), + ) + }, + }, + ] + : []), ]) const handleKeyDown = (event: KeyboardEvent) => { From 5c9cc9c7488f5217080d67bd981d4170b445107a Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 18 Jan 2026 12:05:11 +0000 Subject: [PATCH 086/104] ignore: update download stats 2026-01-18 --- STATS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/STATS.md b/STATS.md index 9a665612b14..a2041d49ac6 100644 --- a/STATS.md +++ b/STATS.md @@ -203,3 +203,4 @@ | 2026-01-14 | 3,568,928 (+271,850) | 1,645,362 (+50,300) | 5,214,290 (+322,150) | | 2026-01-16 | 4,121,550 (+552,622) | 1,754,418 (+109,056) | 5,875,968 (+661,678) | | 2026-01-17 | 4,389,558 (+268,008) | 1,805,315 (+50,897) | 6,194,873 (+318,905) | +| 2026-01-18 | 4,627,623 (+238,065) | 1,839,171 (+33,856) | 6,466,794 (+271,921) | From 6c0991d1623b620e8c95d9ea8a184861efe57f23 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 18 Jan 2026 09:00:49 -0500 Subject: [PATCH 087/104] fix(app): remove redundant toast for thinking effort changes (#9181) --- packages/app/src/pages/session.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index d76ff99b340..f063ce35b40 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -533,10 +533,6 @@ export default function Page() { keybind: "shift+mod+t", onSelect: () => { local.model.variant.cycle() - showToast({ - title: "Thinking effort changed", - description: "The thinking effort has been changed to " + (local.model.variant.current() ?? "Default"), - }) }, }, { From ad2e03284bad0c828aaed8c41cecd67672f4fe4a Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Sun, 18 Jan 2026 15:10:38 +0100 Subject: [PATCH 088/104] refactor(desktop): improve layout and styling of session search button (#9251) --- packages/app/src/components/session/session-header.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 4c709feefec..96ed762c448 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -133,14 +133,14 @@ export function SessionHeader() { class="hidden md:flex w-[320px] p-1 pl-1.5 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-raised-base transition-colors cursor-default hover:bg-surface-raised-base-hover focus:bg-surface-raised-base-hover active:bg-surface-raised-base-active" onClick={() => command.trigger("file.open")} > -
- - +
+ + Search {name()}
- {(keybind) => {keybind()}} + {(keybind) => {keybind()}} )} From 2dcca4755d644b2ec66c7a284484101609db210b Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 18 Jan 2026 09:47:18 -0600 Subject: [PATCH 089/104] fix: import issue in patch module --- packages/opencode/src/patch/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts index 888a4d94b89..0efeff544f6 100644 --- a/packages/opencode/src/patch/index.ts +++ b/packages/opencode/src/patch/index.ts @@ -1,6 +1,7 @@ import z from "zod" import * as path from "path" import * as fs from "fs/promises" +import { readFileSync } from "fs" import { Log } from "../util/log" export namespace Patch { @@ -311,7 +312,7 @@ export namespace Patch { // Read original file content let originalContent: string try { - originalContent = require("fs").readFileSync(filePath, "utf-8") + originalContent = readFileSync(filePath, "utf-8") } catch (error) { throw new Error(`Failed to read file ${filePath}: ${error}`) } From f7fef99ddddb5e8fffa10f392f193e263552d7d0 Mon Sep 17 00:00:00 2001 From: Chawye Hsu Date: Sun, 18 Jan 2026 23:58:34 +0800 Subject: [PATCH 090/104] refactor(installation): update scoop installation method (#9243) Signed-off-by: Chawye Hsu --- README.md | 4 +++- README.zh-CN.md | 4 +++- README.zh-TW.md | 4 +++- packages/opencode/src/installation/index.ts | 4 ++-- packages/web/src/content/docs/index.mdx | 3 +-- 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index d0ba487402f..64ca1ef7a6f 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ curl -fsSL https://opencode.ai/install | bash # Package managers npm i -g opencode-ai@latest # or bun/pnpm/yarn -scoop bucket add extras; scoop install extras/opencode # Windows +scoop install opencode # Windows choco install opencode # Windows brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date) brew install opencode # macOS and Linux (official brew formula, updated less) @@ -52,6 +52,8 @@ OpenCode is also available as a desktop application. Download directly from the ```bash # macOS (Homebrew) brew install --cask opencode-desktop +# Windows (Scoop) +scoop bucket add extras; scoop install extras/opencode-desktop ``` #### Installation Directory diff --git a/README.zh-CN.md b/README.zh-CN.md index 30757f5fe9d..4b56e0fb0b0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -26,7 +26,7 @@ curl -fsSL https://opencode.ai/install | bash # 软件包管理器 npm i -g opencode-ai@latest # 也可使用 bun/pnpm/yarn -scoop bucket add extras; scoop install extras/opencode # Windows +scoop install opencode # Windows choco install opencode # Windows brew install anomalyco/tap/opencode # macOS 和 Linux(推荐,始终保持最新) brew install opencode # macOS 和 Linux(官方 brew formula,更新频率较低) @@ -52,6 +52,8 @@ OpenCode 也提供桌面版应用。可直接从 [发布页 (releases page)](htt ```bash # macOS (Homebrew Cask) brew install --cask opencode-desktop +# Windows (Scoop) +scoop bucket add extras; scoop install extras/opencode-desktop ``` #### 安装目录 diff --git a/README.zh-TW.md b/README.zh-TW.md index 9e27c48f27e..66664a70305 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -26,7 +26,7 @@ curl -fsSL https://opencode.ai/install | bash # 套件管理員 npm i -g opencode-ai@latest # 也可使用 bun/pnpm/yarn -scoop bucket add extras; scoop install extras/opencode # Windows +scoop install opencode # Windows choco install opencode # Windows brew install anomalyco/tap/opencode # macOS 與 Linux(推薦,始終保持最新) brew install opencode # macOS 與 Linux(官方 brew formula,更新頻率較低) @@ -52,6 +52,8 @@ OpenCode 也提供桌面版應用程式。您可以直接從 [發佈頁面 (rele ```bash # macOS (Homebrew Cask) brew install --cask opencode-desktop +# Windows (Scoop) +scoop bucket add extras; scoop install extras/opencode-desktop ``` #### 安裝目錄 diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index dea312adb0c..d18c9e31a13 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -158,7 +158,7 @@ export namespace Installation { cmd = $`echo Y | choco upgrade opencode --version=${target}` break case "scoop": - cmd = $`scoop install extras/opencode@${target}` + cmd = $`scoop install opencode@${target}` break default: throw new Error(`Unknown method: ${method}`) @@ -226,7 +226,7 @@ export namespace Installation { } if (detectedMethod === "scoop") { - return fetch("https://raw.githubusercontent.com/ScoopInstaller/Extras/master/bucket/opencode.json", { + return fetch("https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json", { headers: { Accept: "application/json" }, }) .then((res) => { diff --git a/packages/web/src/content/docs/index.mdx b/packages/web/src/content/docs/index.mdx index bee5bd3a38e..8b3d3a9c824 100644 --- a/packages/web/src/content/docs/index.mdx +++ b/packages/web/src/content/docs/index.mdx @@ -98,8 +98,7 @@ You can also install it with the following commands: - **Using Scoop** ```bash - scoop bucket add extras - scoop install extras/opencode + scoop install opencode ``` - **Using NPM** From 095a64291d8713f7a9b6b2931d28911dc5df9059 Mon Sep 17 00:00:00 2001 From: Lior Date: Sun, 18 Jan 2026 18:45:25 +0200 Subject: [PATCH 091/104] fix(acp): preserve file attachment metadata during session replay (#6342) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> --- packages/opencode/src/acp/agent.ts | 105 +++++++++++++++++++++++++++-- 1 file changed, 98 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 5fca2725587..469b33b025a 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -354,7 +354,7 @@ export namespace ACP { if (part.type === "text") { const delta = props.delta - if (delta && part.synthetic !== true) { + if (delta && part.ignored !== true) { await this.connection .sessionUpdate({ sessionId, @@ -687,7 +687,7 @@ export namespace ACP { break } } else if (part.type === "text") { - if (part.text) { + if (part.text && !part.ignored) { await this.connection .sessionUpdate({ sessionId, @@ -703,6 +703,79 @@ export namespace ACP { log.error("failed to send text to ACP", { error: err }) }) } + } else if (part.type === "file") { + // Replay file attachments as appropriate ACP content blocks. + // OpenCode stores files internally as { type: "file", url, filename, mime }. + // We convert these back to ACP blocks based on the URL scheme and MIME type: + // - file:// URLs → resource_link + // - data: URLs with image/* → image block + // - data: URLs with text/* or application/json → resource with text + // - data: URLs with other types → resource with blob + const url = part.url + const filename = part.filename ?? "file" + const mime = part.mime || "application/octet-stream" + const messageChunk = message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk" + + if (url.startsWith("file://")) { + // Local file reference - send as resource_link + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: messageChunk, + content: { type: "resource_link", uri: url, name: filename, mimeType: mime }, + }, + }) + .catch((err) => { + log.error("failed to send resource_link to ACP", { error: err }) + }) + } else if (url.startsWith("data:")) { + // Embedded content - parse data URL and send as appropriate block type + const base64Match = url.match(/^data:([^;]+);base64,(.*)$/) + const dataMime = base64Match?.[1] + const base64Data = base64Match?.[2] ?? "" + + const effectiveMime = dataMime || mime + + if (effectiveMime.startsWith("image/")) { + // Image - send as image block + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: messageChunk, + content: { + type: "image", + mimeType: effectiveMime, + data: base64Data, + uri: `file://${filename}`, + }, + }, + }) + .catch((err) => { + log.error("failed to send image to ACP", { error: err }) + }) + } else { + // Non-image: text types get decoded, binary types stay as blob + const isText = effectiveMime.startsWith("text/") || effectiveMime === "application/json" + const resource = isText + ? { uri: `file://${filename}`, mimeType: effectiveMime, text: Buffer.from(base64Data, "base64").toString("utf-8") } + : { uri: `file://${filename}`, mimeType: effectiveMime, blob: base64Data } + + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: messageChunk, + content: { type: "resource", resource }, + }, + }) + .catch((err) => { + log.error("failed to send resource to ACP", { error: err }) + }) + } + } + // URLs that don't match file:// or data: are skipped (unsupported) } else if (part.type === "reasoning") { if (part.text) { await this.connection @@ -901,39 +974,57 @@ export namespace ACP { text: part.text, }) break - case "image": + case "image": { + const parsed = parseUri(part.uri ?? "") + const filename = parsed.type === "file" ? parsed.filename : "image" if (part.data) { parts.push({ type: "file", url: `data:${part.mimeType};base64,${part.data}`, - filename: "image", + filename, mime: part.mimeType, }) } else if (part.uri && part.uri.startsWith("http:")) { parts.push({ type: "file", url: part.uri, - filename: "image", + filename, mime: part.mimeType, }) } break + } case "resource_link": const parsed = parseUri(part.uri) + // Use the name from resource_link if available + if (part.name && parsed.type === "file") { + parsed.filename = part.name + } parts.push(parsed) break - case "resource": + case "resource": { const resource = part.resource - if ("text" in resource) { + if ("text" in resource && resource.text) { parts.push({ type: "text", text: resource.text, }) + } else if ("blob" in resource && resource.blob && resource.mimeType) { + // Binary resource (PDFs, etc.): store as file part with data URL + const parsed = parseUri(resource.uri ?? "") + const filename = parsed.type === "file" ? parsed.filename : "file" + parts.push({ + type: "file", + url: `data:${resource.mimeType};base64,${resource.blob}`, + filename, + mime: resource.mimeType, + }) } break + } default: break From 5009f10406c15c4b69c04fa626756ee7bf81b300 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 18 Jan 2026 16:46:02 +0000 Subject: [PATCH 092/104] chore: generate --- packages/opencode/src/acp/agent.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 469b33b025a..6330fae97a2 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -707,7 +707,7 @@ export namespace ACP { // Replay file attachments as appropriate ACP content blocks. // OpenCode stores files internally as { type: "file", url, filename, mime }. // We convert these back to ACP blocks based on the URL scheme and MIME type: - // - file:// URLs → resource_link + // - file:// URLs → resource_link // - data: URLs with image/* → image block // - data: URLs with text/* or application/json → resource with text // - data: URLs with other types → resource with blob @@ -759,7 +759,11 @@ export namespace ACP { // Non-image: text types get decoded, binary types stay as blob const isText = effectiveMime.startsWith("text/") || effectiveMime === "application/json" const resource = isText - ? { uri: `file://${filename}`, mimeType: effectiveMime, text: Buffer.from(base64Data, "base64").toString("utf-8") } + ? { + uri: `file://${filename}`, + mimeType: effectiveMime, + text: Buffer.from(base64Data, "base64").toString("utf-8"), + } : { uri: `file://${filename}`, mimeType: effectiveMime, blob: base64Data } await this.connection From dac099a4892689d11abedb0fcc1098b50e0958c8 Mon Sep 17 00:00:00 2001 From: Caleb Norton Date: Sun, 18 Jan 2026 11:14:13 -0600 Subject: [PATCH 093/104] feat(nix): overhaul nix flake and packages (#9032) --- .github/workflows/update-nix-hashes.yml | 77 --------- flake.lock | 6 +- flake.nix | 110 +++--------- nix/bundle.ts | 40 ----- nix/desktop.nix | 191 +++++++-------------- nix/node-modules.nix | 62 ------- nix/opencode.nix | 211 ++++++++++++++---------- nix/scripts/bun-build.ts | 120 -------------- nix/scripts/patch-wasm.ts | 43 ----- packages/opencode/script/build.ts | 5 + 10 files changed, 212 insertions(+), 653 deletions(-) delete mode 100644 nix/bundle.ts delete mode 100644 nix/node-modules.nix delete mode 100644 nix/scripts/bun-build.ts delete mode 100644 nix/scripts/patch-wasm.ts diff --git a/.github/workflows/update-nix-hashes.yml b/.github/workflows/update-nix-hashes.yml index f80a57d25d8..f9817fe1eac 100644 --- a/.github/workflows/update-nix-hashes.yml +++ b/.github/workflows/update-nix-hashes.yml @@ -19,84 +19,7 @@ on: - ".github/workflows/update-nix-hashes.yml" jobs: - update-flake: - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: blacksmith-4vcpu-ubuntu-2404 - env: - TITLE: flake.lock - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - token: ${{ secrets.GITHUB_TOKEN }} - fetch-depth: 0 - ref: ${{ github.head_ref || github.ref_name }} - repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} - - - name: Setup Nix - uses: nixbuild/nix-quick-install-action@v34 - - - name: Configure git - run: | - git config --global user.email "action@github.com" - git config --global user.name "Github Action" - - - name: Update ${{ env.TITLE }} - run: | - set -euo pipefail - echo "Updating $TITLE..." - nix flake update - echo "$TITLE updated successfully" - - - name: Commit ${{ env.TITLE }} changes - env: - TARGET_BRANCH: ${{ github.head_ref || github.ref_name }} - run: | - set -euo pipefail - - echo "Checking for changes in tracked files..." - - summarize() { - local status="$1" - { - echo "### Nix $TITLE" - echo "" - echo "- ref: ${GITHUB_REF_NAME}" - echo "- status: ${status}" - } >> "$GITHUB_STEP_SUMMARY" - if [ -n "${GITHUB_SERVER_URL:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ] && [ -n "${GITHUB_RUN_ID:-}" ]; then - echo "- run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" >> "$GITHUB_STEP_SUMMARY" - fi - echo "" >> "$GITHUB_STEP_SUMMARY" - } - FILES=(flake.lock flake.nix) - STATUS="$(git status --short -- "${FILES[@]}" || true)" - if [ -z "$STATUS" ]; then - echo "No changes detected." - summarize "no changes" - exit 0 - fi - - echo "Changes detected:" - echo "$STATUS" - echo "Staging files..." - git add "${FILES[@]}" - echo "Committing changes..." - git commit -m "Update $TITLE" - echo "Changes committed" - - BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}" - echo "Pulling latest from branch: $BRANCH" - git pull --rebase --autostash origin "$BRANCH" - echo "Pushing changes to branch: $BRANCH" - git push origin HEAD:"$BRANCH" - echo "Changes pushed successfully" - - summarize "committed $(git rev-parse --short HEAD)" - compute-node-modules-hash: - needs: update-flake if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository strategy: fail-fast: false diff --git a/flake.lock b/flake.lock index 2bfad510e7b..5ef276f0a08 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1768456270, - "narHash": "sha256-NgaL2CCiUR6nsqUIY4yxkzz07iQUlUCany44CFv+OxY=", + "lastModified": 1768302833, + "narHash": "sha256-h5bRFy9bco+8QcK7rGoOiqMxMbmn21moTACofNLRMP4=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "f4606b01b39e09065df37905a2133905246db9ed", + "rev": "61db79b0c6b838d9894923920b612048e1201926", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 32614640ad3..20833fc49ed 100644 --- a/flake.nix +++ b/flake.nix @@ -6,11 +6,7 @@ }; outputs = - { - self, - nixpkgs, - ... - }: + { self, nixpkgs, ... }: let systems = [ "aarch64-linux" @@ -18,99 +14,35 @@ "aarch64-darwin" "x86_64-darwin" ]; - inherit (nixpkgs) lib; - forEachSystem = lib.genAttrs systems; - pkgsFor = system: nixpkgs.legacyPackages.${system}; - packageJson = builtins.fromJSON (builtins.readFile ./packages/opencode/package.json); - bunTarget = { - "aarch64-linux" = "bun-linux-arm64"; - "x86_64-linux" = "bun-linux-x64"; - "aarch64-darwin" = "bun-darwin-arm64"; - "x86_64-darwin" = "bun-darwin-x64"; - }; - - # Parse "bun-{os}-{cpu}" to {os, cpu} - parseBunTarget = - target: - let - parts = lib.splitString "-" target; - in - { - os = builtins.elemAt parts 1; - cpu = builtins.elemAt parts 2; - }; - - hashesFile = "${./nix}/hashes.json"; - hashesData = - if builtins.pathExists hashesFile then builtins.fromJSON (builtins.readFile hashesFile) else { }; - # Lookup hash: supports per-system ({system: hash}) or legacy single hash - nodeModulesHashFor = - system: - if builtins.isAttrs hashesData.nodeModules then - hashesData.nodeModules.${system} - else - hashesData.nodeModules; - modelsDev = forEachSystem ( - system: - let - pkgs = pkgsFor system; - in - pkgs."models-dev" - ); + forEachSystem = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system}); + rev = self.shortRev or self.dirtyShortRev or "dirty"; in { - devShells = forEachSystem ( - system: - let - pkgs = pkgsFor system; - in - { - default = pkgs.mkShell { - packages = with pkgs; [ - bun - nodejs_20 - pkg-config - openssl - git - ]; - }; - } - ); + devShells = forEachSystem (pkgs: { + default = pkgs.mkShell { + packages = with pkgs; [ + bun + nodejs_20 + pkg-config + openssl + git + ]; + }; + }); packages = forEachSystem ( - system: + pkgs: let - pkgs = pkgsFor system; - bunPlatform = parseBunTarget bunTarget.${system}; - mkNodeModules = pkgs.callPackage ./nix/node-modules.nix { - hash = nodeModulesHashFor system; - bunCpu = bunPlatform.cpu; - bunOs = bunPlatform.os; + opencode = pkgs.callPackage ./nix/opencode.nix { + inherit rev; }; - mkOpencode = pkgs.callPackage ./nix/opencode.nix { }; - mkDesktop = pkgs.callPackage ./nix/desktop.nix { }; - - opencodePkg = mkOpencode { - inherit (packageJson) version; - src = ./.; - scripts = ./nix/scripts; - target = bunTarget.${system}; - modelsDev = "${modelsDev.${system}}/dist/_api.json"; - inherit mkNodeModules; - }; - - desktopPkg = mkDesktop { - inherit (packageJson) version; - src = ./.; - scripts = ./nix/scripts; - mkNodeModules = mkNodeModules; - opencode = opencodePkg; + desktop = pkgs.callPackage ./nix/desktop.nix { + inherit opencode; }; in { - default = self.packages.${system}.opencode; - opencode = opencodePkg; - desktop = desktopPkg; + default = opencode; + inherit opencode desktop; } ); }; diff --git a/nix/bundle.ts b/nix/bundle.ts deleted file mode 100644 index effb1dff7cc..00000000000 --- a/nix/bundle.ts +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env bun - -import solidPlugin from "./node_modules/@opentui/solid/scripts/solid-plugin" -import path from "path" -import fs from "fs" - -const dir = process.cwd() -const parser = fs.realpathSync(path.join(dir, "node_modules/@opentui/core/parser.worker.js")) -const worker = "./src/cli/cmd/tui/worker.ts" -const version = process.env.OPENCODE_VERSION ?? "local" -const channel = process.env.OPENCODE_CHANNEL ?? "local" - -fs.rmSync(path.join(dir, "dist"), { recursive: true, force: true }) - -const result = await Bun.build({ - entrypoints: ["./src/index.ts", worker, parser], - outdir: "./dist", - target: "bun", - sourcemap: "none", - tsconfig: "./tsconfig.json", - plugins: [solidPlugin], - external: ["@opentui/core"], - define: { - OPENCODE_VERSION: `'${version}'`, - OPENCODE_CHANNEL: `'${channel}'`, - // Leave undefined so runtime picks bundled/dist worker or fallback in code. - OPENCODE_WORKER_PATH: "undefined", - OTUI_TREE_SITTER_WORKER_PATH: 'new URL("./cli/cmd/tui/parser.worker.js", import.meta.url).href', - }, -}) - -if (!result.success) { - console.error("bundle failed") - for (const log of result.logs) console.error(log) - process.exit(1) -} - -const parserOut = path.join(dir, "dist/src/cli/cmd/tui/parser.worker.js") -fs.mkdirSync(path.dirname(parserOut), { recursive: true }) -await Bun.write(parserOut, Bun.file(parser)) diff --git a/nix/desktop.nix b/nix/desktop.nix index 9fb73b56316..9625f75c271 100644 --- a/nix/desktop.nix +++ b/nix/desktop.nix @@ -2,166 +2,99 @@ lib, stdenv, rustPlatform, - bun, pkg-config, - dbus ? null, - openssl, - glib ? null, - gtk3 ? null, - libsoup_3 ? null, - webkitgtk_4_1 ? null, - librsvg ? null, - libappindicator-gtk3 ? null, + cargo-tauri, + bun, + nodejs, cargo, rustc, - makeBinaryWrapper, - copyDesktopItems, - makeDesktopItem, - nodejs, jq, + wrapGAppsHook4, + makeWrapper, + dbus, + glib, + gtk4, + libsoup_3, + librsvg, + libappindicator, + glib-networking, + openssl, + webkitgtk_4_1, + gst_all_1, + opencode, }: -args: -let - scripts = args.scripts; - mkModules = - attrs: - args.mkNodeModules ( - attrs - // { - canonicalizeScript = scripts + "/canonicalize-node-modules.ts"; - normalizeBinsScript = scripts + "/normalize-bun-binaries.ts"; - } - ); -in -rustPlatform.buildRustPackage rec { +rustPlatform.buildRustPackage (finalAttrs: { pname = "opencode-desktop"; - version = args.version; + inherit (opencode) + version + src + node_modules + patches + ; - src = args.src; - - # We need to set the root for cargo, but we also need access to the whole repo. - postUnpack = '' - # Update sourceRoot to point to the tauri app - sourceRoot+=/packages/desktop/src-tauri - ''; - - cargoLock = { - lockFile = ../packages/desktop/src-tauri/Cargo.lock; - allowBuiltinFetchGit = true; - }; - - node_modules = mkModules { - version = version; - src = src; - }; + cargoRoot = "packages/desktop/src-tauri"; + cargoLock.lockFile = ../packages/desktop/src-tauri/Cargo.lock; + buildAndTestSubdir = finalAttrs.cargoRoot; nativeBuildInputs = [ pkg-config + cargo-tauri.hook bun - makeBinaryWrapper - copyDesktopItems + nodejs # for patchShebangs node_modules cargo rustc - nodejs jq - ]; - - # based on packages/desktop/src-tauri/release/appstream.metainfo.xml - desktopItems = lib.optionals stdenv.isLinux [ - (makeDesktopItem { - name = "ai.opencode.opencode"; - desktopName = "OpenCode"; - comment = "Open source AI coding agent"; - exec = "opencode-desktop"; - icon = "opencode"; - terminal = false; - type = "Application"; - categories = [ "Development" "IDE" ]; - startupWMClass = "opencode"; - }) - ]; - - buildInputs = [ - openssl + makeWrapper ] - ++ lib.optionals stdenv.isLinux [ + ++ lib.optionals stdenv.hostPlatform.isLinux [ wrapGAppsHook4 ]; + + buildInputs = lib.optionals stdenv.isLinux [ dbus glib - gtk3 + gtk4 libsoup_3 - webkitgtk_4_1 librsvg - libappindicator-gtk3 + libappindicator + glib-networking + openssl + webkitgtk_4_1 + gst_all_1.gstreamer + gst_all_1.gst-plugins-base + gst_all_1.gst-plugins-good ]; - preBuild = '' - # Restore node_modules - pushd ../../.. - - # Copy node_modules from the fixed-output derivation - # We use cp -r --no-preserve=mode to ensure we can write to them if needed, - # though we usually just read. - cp -r ${node_modules}/node_modules . - cp -r ${node_modules}/packages . + strictDeps = true; - # Ensure node_modules is writable so patchShebangs can update script headers - chmod -R u+w node_modules - # Ensure workspace packages are writable for tsgo incremental outputs (.tsbuildinfo) - chmod -R u+w packages - # Patch shebangs so scripts can run + preBuild = '' + cp -a ${finalAttrs.node_modules}/{node_modules,packages} . + chmod -R u+w node_modules packages patchShebangs node_modules + patchShebangs packages/desktop/node_modules - # Copy sidecar mkdir -p packages/desktop/src-tauri/sidecars - targetTriple=${stdenv.hostPlatform.rust.rustcTarget} - cp ${args.opencode}/bin/opencode packages/desktop/src-tauri/sidecars/opencode-cli-$targetTriple - - # Merge prod config into tauri.conf.json - if ! jq -s '.[0] * .[1]' \ - packages/desktop/src-tauri/tauri.conf.json \ - packages/desktop/src-tauri/tauri.prod.conf.json \ - > packages/desktop/src-tauri/tauri.conf.json.tmp; then - echo "Error: failed to merge tauri.conf.json with tauri.prod.conf.json" >&2 - exit 1 - fi - mv packages/desktop/src-tauri/tauri.conf.json.tmp packages/desktop/src-tauri/tauri.conf.json - - # Build the frontend - cd packages/desktop - - # The 'build' script runs 'bun run typecheck && vite build'. - bun run build - - popd + cp ${opencode}/bin/opencode packages/desktop/src-tauri/sidecars/opencode-cli-${stdenv.hostPlatform.rust.rustcTarget} ''; - # Tauri bundles the assets during the rust build phase (which happens after preBuild). - # It looks for them in the location specified in tauri.conf.json. - - postInstall = lib.optionalString stdenv.isLinux '' - # Install icon - mkdir -p $out/share/icons/hicolor/128x128/apps - cp ../../../packages/desktop/src-tauri/icons/prod/128x128.png $out/share/icons/hicolor/128x128/apps/opencode.png + # see publish-tauri job in .github/workflows/publish.yml + tauriBuildFlags = [ + "--config" + "tauri.prod.conf.json" + "--no-sign" # no code signing or auto updates + ]; - # Wrap the binary to ensure it finds the libraries - wrapProgram $out/bin/opencode-desktop \ - --prefix LD_LIBRARY_PATH : ${ - lib.makeLibraryPath [ - gtk3 - webkitgtk_4_1 - librsvg - glib - libsoup_3 - ] - } + # FIXME: workaround for concerns about case insensitive filesystems + # should be removed once binary is renamed or decided otherwise + # darwin output is a .app bundle so no conflict + postFixup = lib.optionalString stdenv.hostPlatform.isLinux '' + mv $out/bin/OpenCode $out/bin/opencode-desktop + sed -i 's|^Exec=OpenCode$|Exec=opencode-desktop|' $out/share/applications/OpenCode.desktop ''; - meta = with lib; { + meta = { description = "OpenCode Desktop App"; homepage = "https://opencode.ai"; - license = licenses.mit; - maintainers = with maintainers; [ ]; + license = lib.licenses.mit; mainProgram = "opencode-desktop"; - platforms = platforms.linux ++ platforms.darwin; + inherit (opencode.meta) platforms; }; -} +}) \ No newline at end of file diff --git a/nix/node-modules.nix b/nix/node-modules.nix deleted file mode 100644 index 2a8f0a47cb0..00000000000 --- a/nix/node-modules.nix +++ /dev/null @@ -1,62 +0,0 @@ -{ - hash, - lib, - stdenvNoCC, - bun, - cacert, - curl, - bunCpu, - bunOs, -}: -args: -stdenvNoCC.mkDerivation { - pname = "opencode-node_modules"; - inherit (args) version src; - - impureEnvVars = lib.fetchers.proxyImpureEnvVars ++ [ - "GIT_PROXY_COMMAND" - "SOCKS_SERVER" - ]; - - nativeBuildInputs = [ - bun - cacert - curl - ]; - - dontConfigure = true; - - buildPhase = '' - runHook preBuild - export HOME=$(mktemp -d) - export BUN_INSTALL_CACHE_DIR=$(mktemp -d) - bun install \ - --cpu="${bunCpu}" \ - --os="${bunOs}" \ - --frozen-lockfile \ - --ignore-scripts \ - --no-progress \ - --linker=isolated - bun --bun ${args.canonicalizeScript} - bun --bun ${args.normalizeBinsScript} - runHook postBuild - ''; - - installPhase = '' - runHook preInstall - mkdir -p $out - while IFS= read -r dir; do - rel="''${dir#./}" - dest="$out/$rel" - mkdir -p "$(dirname "$dest")" - cp -R "$dir" "$dest" - done < <(find . -type d -name node_modules -prune | sort) - runHook postInstall - ''; - - dontFixup = true; - - outputHashAlgo = "sha256"; - outputHashMode = "recursive"; - outputHash = hash; -} diff --git a/nix/opencode.nix b/nix/opencode.nix index 714aabe094f..4d6f8e9b423 100644 --- a/nix/opencode.nix +++ b/nix/opencode.nix @@ -2,60 +2,115 @@ lib, stdenvNoCC, bun, - ripgrep, + sysctl, makeBinaryWrapper, + models-dev, + ripgrep, + installShellFiles, + versionCheckHook, + writableTmpDirAsHomeHook, + rev ? "dirty", }: -args: let - inherit (args) scripts; - mkModules = - attrs: - args.mkNodeModules ( - attrs - // { - canonicalizeScript = scripts + "/canonicalize-node-modules.ts"; - normalizeBinsScript = scripts + "/normalize-bun-binaries.ts"; - } - ); + packageJson = lib.pipe ../packages/opencode/package.json [ + builtins.readFile + builtins.fromJSON + ]; in stdenvNoCC.mkDerivation (finalAttrs: { pname = "opencode"; - inherit (args) version src; + version = "${packageJson.version}-${rev}"; + + src = lib.fileset.toSource { + root = ../.; + fileset = lib.fileset.intersection (lib.fileset.fromSource (lib.sources.cleanSource ../.)) ( + lib.fileset.unions [ + ../packages + ../bun.lock + ../package.json + ../patches + ../install + ] + ); + }; - node_modules = mkModules { + node_modules = stdenvNoCC.mkDerivation { + pname = "${finalAttrs.pname}-node_modules"; inherit (finalAttrs) version src; + + impureEnvVars = lib.fetchers.proxyImpureEnvVars ++ [ + "GIT_PROXY_COMMAND" + "SOCKS_SERVER" + ]; + + nativeBuildInputs = [ + bun + ]; + + dontConfigure = true; + + buildPhase = '' + runHook preBuild + export HOME=$(mktemp -d) + export BUN_INSTALL_CACHE_DIR=$(mktemp -d) + bun install \ + --cpu="${if stdenvNoCC.hostPlatform.isAarch64 then "arm64" else "x64"}" \ + --os="${if stdenvNoCC.hostPlatform.isLinux then "linux" else "darwin"}" \ + --frozen-lockfile \ + --ignore-scripts \ + --no-progress \ + --linker=isolated + bun --bun ${./scripts/canonicalize-node-modules.ts} + bun --bun ${./scripts/normalize-bun-binaries.ts} + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + mkdir -p $out + find . -type d -name node_modules -exec cp -R --parents {} $out \; + + runHook postInstall + ''; + + dontFixup = true; + + outputHashAlgo = "sha256"; + outputHashMode = "recursive"; + outputHash = + (lib.pipe ./hashes.json [ + builtins.readFile + builtins.fromJSON + ]).nodeModules.${stdenvNoCC.hostPlatform.system}; }; nativeBuildInputs = [ bun + installShellFiles makeBinaryWrapper + models-dev + writableTmpDirAsHomeHook ]; - env.MODELS_DEV_API_JSON = args.modelsDev; - env.OPENCODE_VERSION = args.version; - env.OPENCODE_CHANNEL = "stable"; - dontConfigure = true; + configurePhase = '' + runHook preConfigure - buildPhase = '' - runHook preBuild + cp -R ${finalAttrs.node_modules}/. . - cp -r ${finalAttrs.node_modules}/node_modules . - cp -r ${finalAttrs.node_modules}/packages . + runHook postConfigure + ''; - ( - cd packages/opencode + env.MODELS_DEV_API_JSON = "${models-dev}/dist/_api.json"; + env.OPENCODE_VERSION = finalAttrs.version; + env.OPENCODE_CHANNEL = "local"; - chmod -R u+w ./node_modules - mkdir -p ./node_modules/@opencode-ai - rm -f ./node_modules/@opencode-ai/{script,sdk,plugin} - ln -s $(pwd)/../../packages/script ./node_modules/@opencode-ai/script - ln -s $(pwd)/../../packages/sdk/js ./node_modules/@opencode-ai/sdk - ln -s $(pwd)/../../packages/plugin ./node_modules/@opencode-ai/plugin + buildPhase = '' + runHook preBuild - cp ${./bundle.ts} ./bundle.ts - chmod +x ./bundle.ts - bun run ./bundle.ts - ) + cd ./packages/opencode + bun --bun ./script/build.ts --single --skip-install + bun --bun ./script/schema.ts schema.json runHook postBuild ''; @@ -63,76 +118,52 @@ stdenvNoCC.mkDerivation (finalAttrs: { installPhase = '' runHook preInstall - cd packages/opencode - if [ ! -d dist ]; then - echo "ERROR: dist directory missing after bundle step" - exit 1 - fi - - mkdir -p $out/lib/opencode - cp -r dist $out/lib/opencode/ - chmod -R u+w $out/lib/opencode/dist - - # Select bundled worker assets deterministically (sorted find output) - worker_file=$(find "$out/lib/opencode/dist" -type f \( -path '*/tui/worker.*' -o -name 'worker.*' \) | sort | head -n1) - parser_worker_file=$(find "$out/lib/opencode/dist" -type f -name 'parser.worker.*' | sort | head -n1) - if [ -z "$worker_file" ]; then - echo "ERROR: bundled worker not found" - exit 1 - fi - - main_wasm=$(printf '%s\n' "$out"/lib/opencode/dist/tree-sitter-*.wasm | sort | head -n1) - wasm_list=$(find "$out/lib/opencode/dist" -maxdepth 1 -name 'tree-sitter-*.wasm' -print) - for patch_file in "$worker_file" "$parser_worker_file"; do - [ -z "$patch_file" ] && continue - [ ! -f "$patch_file" ] && continue - if [ -n "$wasm_list" ] && grep -q 'tree-sitter' "$patch_file"; then - # Rewrite wasm references to absolute store paths to avoid runtime resolve failures. - bun --bun ${scripts + "/patch-wasm.ts"} "$patch_file" "$main_wasm" $wasm_list - fi - done - - mkdir -p $out/lib/opencode/node_modules - cp -r ../../node_modules/.bun $out/lib/opencode/node_modules/ - mkdir -p $out/lib/opencode/node_modules/@opentui - - mkdir -p $out/bin - makeWrapper ${bun}/bin/bun $out/bin/opencode \ - --add-flags "run" \ - --add-flags "$out/lib/opencode/dist/src/index.js" \ - --prefix PATH : ${lib.makeBinPath [ ripgrep ]} \ - --argv0 opencode + install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode + install -Dm644 schema.json $out/share/opencode/schema.json + + wrapProgram $out/bin/opencode \ + --prefix PATH : ${ + lib.makeBinPath ( + [ + ripgrep + ] + # bun runs sysctl to detect if dunning on rosetta2 + ++ lib.optional stdenvNoCC.hostPlatform.isDarwin sysctl + ) + } runHook postInstall ''; - postInstall = '' - for pkg in $out/lib/opencode/node_modules/.bun/@opentui+core-* $out/lib/opencode/node_modules/.bun/@opentui+solid-* $out/lib/opencode/node_modules/.bun/@opentui+core@* $out/lib/opencode/node_modules/.bun/@opentui+solid@*; do - if [ -d "$pkg" ]; then - pkgName=$(basename "$pkg" | sed 's/@opentui+\([^@]*\)@.*/\1/') - ln -sf ../.bun/$(basename "$pkg")/node_modules/@opentui/$pkgName \ - $out/lib/opencode/node_modules/@opentui/$pkgName - fi - done + postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) '' + # trick yargs into also generating zsh completions + installShellCompletion --cmd opencode \ + --bash <($out/bin/opencode completion) \ + --zsh <(SHELL=/bin/zsh $out/bin/opencode completion) ''; - dontFixup = true; + nativeInstallCheckInputs = [ + versionCheckHook + writableTmpDirAsHomeHook + ]; + doInstallCheck = true; + versionCheckKeepEnvironment = [ "HOME" ]; + versionCheckProgramArg = "--version"; + + passthru = { + jsonschema = "${placeholder "out"}/share/opencode/schema.json"; + }; meta = { - description = "AI coding agent built for the terminal"; - longDescription = '' - OpenCode is a terminal-based agent that can build anything. - It combines a TypeScript/JavaScript core with a Go-based TUI - to provide an interactive AI coding experience. - ''; - homepage = "https://github.com/anomalyco/opencode"; + description = "The open source coding agent"; + homepage = "https://opencode.ai/"; license = lib.licenses.mit; + mainProgram = "opencode"; platforms = [ "aarch64-linux" "x86_64-linux" "aarch64-darwin" "x86_64-darwin" ]; - mainProgram = "opencode"; }; }) diff --git a/nix/scripts/bun-build.ts b/nix/scripts/bun-build.ts deleted file mode 100644 index e607676cb11..00000000000 --- a/nix/scripts/bun-build.ts +++ /dev/null @@ -1,120 +0,0 @@ -import solidPlugin from "./packages/opencode/node_modules/@opentui/solid/scripts/solid-plugin" -import path from "path" -import fs from "fs" - -const version = "@VERSION@" -const pkg = path.join(process.cwd(), "packages/opencode") -const parser = fs.realpathSync(path.join(pkg, "./node_modules/@opentui/core/parser.worker.js")) -const worker = "./src/cli/cmd/tui/worker.ts" -const target = process.env["BUN_COMPILE_TARGET"] - -if (!target) { - throw new Error("BUN_COMPILE_TARGET not set") -} - -process.chdir(pkg) - -const manifestName = "opencode-assets.manifest" -const manifestPath = path.join(pkg, manifestName) - -const readTrackedAssets = () => { - if (!fs.existsSync(manifestPath)) return [] - return fs - .readFileSync(manifestPath, "utf8") - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0) -} - -const removeTrackedAssets = () => { - for (const file of readTrackedAssets()) { - const filePath = path.join(pkg, file) - if (fs.existsSync(filePath)) { - fs.rmSync(filePath, { force: true }) - } - } -} - -const assets = new Set() - -const addAsset = async (p: string) => { - const file = path.basename(p) - const dest = path.join(pkg, file) - await Bun.write(dest, Bun.file(p)) - assets.add(file) -} - -removeTrackedAssets() - -const result = await Bun.build({ - conditions: ["browser"], - tsconfig: "./tsconfig.json", - plugins: [solidPlugin], - sourcemap: "external", - entrypoints: ["./src/index.ts", parser, worker], - define: { - OPENCODE_VERSION: `'@VERSION@'`, - OTUI_TREE_SITTER_WORKER_PATH: "/$bunfs/root/" + path.relative(pkg, parser).replace(/\\/g, "/"), - OPENCODE_CHANNEL: "'latest'", - }, - compile: { - target, - outfile: "opencode", - autoloadBunfig: false, - autoloadDotenv: false, - //@ts-ignore (bun types aren't up to date) - autoloadTsconfig: true, - autoloadPackageJson: true, - execArgv: ["--user-agent=opencode/" + version, "--use-system-ca", "--"], - windows: {}, - }, -}) - -if (!result.success) { - console.error("Build failed!") - for (const log of result.logs) { - console.error(log) - } - throw new Error("Compilation failed") -} - -const assetOutputs = result.outputs?.filter((x) => x.kind === "asset") ?? [] -for (const x of assetOutputs) { - await addAsset(x.path) -} - -const bundle = await Bun.build({ - entrypoints: [worker], - tsconfig: "./tsconfig.json", - plugins: [solidPlugin], - target: "bun", - outdir: "./.opencode-worker", - sourcemap: "none", -}) - -if (!bundle.success) { - console.error("Worker build failed!") - for (const log of bundle.logs) { - console.error(log) - } - throw new Error("Worker compilation failed") -} - -const workerAssets = bundle.outputs?.filter((x) => x.kind === "asset") ?? [] -for (const x of workerAssets) { - await addAsset(x.path) -} - -const output = bundle.outputs.find((x) => x.kind === "entry-point") -if (!output) { - throw new Error("Worker build produced no entry-point output") -} - -const dest = path.join(pkg, "opencode-worker.js") -await Bun.write(dest, Bun.file(output.path)) -fs.rmSync(path.dirname(output.path), { recursive: true, force: true }) - -const list = Array.from(assets) -await Bun.write(manifestPath, list.length > 0 ? list.join("\n") + "\n" : "") - -console.log("Build successful!") diff --git a/nix/scripts/patch-wasm.ts b/nix/scripts/patch-wasm.ts deleted file mode 100644 index 88a06c2bd2b..00000000000 --- a/nix/scripts/patch-wasm.ts +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bun - -import fs from "fs" -import path from "path" - -/** - * Rewrite tree-sitter wasm references inside a JS file to absolute paths. - * argv: [node, script, file, mainWasm, ...wasmPaths] - */ -const [, , file, mainWasm, ...wasmPaths] = process.argv - -if (!file || !mainWasm) { - console.error("usage: patch-wasm [wasmPaths...]") - process.exit(1) -} - -const content = fs.readFileSync(file, "utf8") -const byName = new Map() - -for (const wasm of wasmPaths) { - const name = path.basename(wasm) - byName.set(name, wasm) -} - -let next = content - -for (const [name, wasmPath] of byName) { - next = next.replaceAll(name, wasmPath) -} - -next = next.replaceAll("tree-sitter.wasm", mainWasm).replaceAll("web-tree-sitter/tree-sitter.wasm", mainWasm) - -// Collapse any relative prefixes before absolute store paths (e.g., "../../../..//nix/store/...") -const nixStorePrefix = process.env.NIX_STORE || "/nix/store" -next = next.replace(/(\.\/)+/g, "./") -next = next.replace( - new RegExp(`(\\.\\.\\/)+\\/{1,2}(${nixStorePrefix.replace(/^\//, "").replace(/\//g, "\\/")}[^"']+)`, "g"), - "/$2", -) -next = next.replace(new RegExp(`(["'])\\/{2,}(\\/${nixStorePrefix.replace(/\//g, "\\/")}[^"']+)(["'])`, "g"), "$1$2$3") -next = next.replace(new RegExp(`(["'])\\/\\/(${nixStorePrefix.replace(/\//g, "\\/")}[^"']+)(["'])`, "g"), "$1$2$3") - -if (next !== content) fs.writeFileSync(file, next) diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 61a665312f0..cb88db2c478 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -90,6 +90,11 @@ const targets = singleFlag return baselineFlag } + // also skip abi-specific builds for the same reason + if (item.abi !== undefined) { + return false + } + return true }) : allTargets From bfd2f91d5b4b7ee28346bfcfd1481a8c0370574c Mon Sep 17 00:00:00 2001 From: Spoon <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Sun, 18 Jan 2026 20:11:22 +0100 Subject: [PATCH 094/104] feat(hook): command execute before hook (#9267) --- packages/opencode/src/session/prompt.ts | 10 ++++++++++ packages/plugin/src/index.ts | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0d3d25feb8d..f4793d1a798 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1702,6 +1702,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the : await lastModel(input.sessionID) : taskModel + await Plugin.trigger( + "command.execute.before", + { + command: input.command, + sessionID: input.sessionID, + arguments: input.arguments, + }, + { parts }, + ) + const result = (await prompt({ sessionID: input.sessionID, messageID: input.messageID, diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index e57eff579e6..36a4657d74c 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -173,6 +173,10 @@ export interface Hooks { output: { temperature: number; topP: number; topK: number; options: Record }, ) => Promise "permission.ask"?: (input: Permission, output: { status: "ask" | "deny" | "allow" }) => Promise + "command.execute.before"?: ( + input: { command: string; sessionID: string; arguments: string }, + output: { parts: Part[] }, + ) => Promise "tool.execute.before"?: ( input: { tool: string; sessionID: string; callID: string }, output: { args: any }, From 501ef2d989afde09b54299d309442a7b1a39a680 Mon Sep 17 00:00:00 2001 From: Vladimir Glafirov Date: Sun, 18 Jan 2026 20:11:34 +0100 Subject: [PATCH 095/104] fix: update gitlab-ai-provider to 1.3.2 (#9279) --- bun.lock | 4 ++-- packages/opencode/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 9cda088153c..a9cabb31114 100644 --- a/bun.lock +++ b/bun.lock @@ -281,7 +281,7 @@ "@ai-sdk/vercel": "1.0.31", "@ai-sdk/xai": "2.0.51", "@clack/prompts": "1.0.0-alpha.1", - "@gitlab/gitlab-ai-provider": "3.1.1", + "@gitlab/gitlab-ai-provider": "3.1.2", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.25.2", @@ -917,7 +917,7 @@ "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], - "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.1.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-7AtFrCflq2NzC99bj7YaqbQDCZyaScM1+L4ujllV5syiRTFE239Uhnd/yEkPXa7sUAnNRfN3CWusCkQ2zK/q9g=="], + "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.1.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-p0NZhZJSavWDX9r/Px/mOK2YIC803GZa8iRzcg3f1C6S0qfea/HBTe4/NWvT2+2kWIwhCePGuI4FN2UFiUWXUg=="], "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 757e6efde90..e1918193470 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -70,7 +70,7 @@ "@ai-sdk/vercel": "1.0.31", "@ai-sdk/xai": "2.0.51", "@clack/prompts": "1.0.0-alpha.1", - "@gitlab/gitlab-ai-provider": "3.1.1", + "@gitlab/gitlab-ai-provider": "3.1.2", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.25.2", From 38c641a2fc6d45c504d419609359f64710a4e732 Mon Sep 17 00:00:00 2001 From: zerone0x Date: Mon, 19 Jan 2026 03:17:49 +0800 Subject: [PATCH 096/104] fix(tool): treat .fbs files as text instead of images (#9276) Co-authored-by: Claude --- packages/opencode/src/tool/read.ts | 4 +++- packages/opencode/test/tool/read.test.ts | 29 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index ce4ab28619d..3b1484cbc0f 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -59,7 +59,9 @@ export const ReadTool = Tool.define("read", { throw new Error(`File not found: ${filepath}`) } - const isImage = file.type.startsWith("image/") && file.type !== "image/svg+xml" + // Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files) + const isImage = + file.type.startsWith("image/") && file.type !== "image/svg+xml" && file.type !== "image/vnd.fastbidsheet" const isPdf = file.type === "application/pdf" if (isImage || isPdf) { const mime = file.type diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 04ffc80ea67..7250bd2fd1e 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -300,4 +300,33 @@ describe("tool.read truncation", () => { }, }) }) + + test(".fbs files (FlatBuffers schema) are read as text, not images", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + // FlatBuffers schema content + const fbsContent = `namespace MyGame; + +table Monster { + pos:Vec3; + name:string; + inventory:[ubyte]; +} + +root_type Monster;` + await Bun.write(path.join(dir, "schema.fbs"), fbsContent) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + const result = await read.execute({ filePath: path.join(tmp.path, "schema.fbs") }, ctx) + // Should be read as text, not as image + expect(result.attachments).toBeUndefined() + expect(result.output).toContain("namespace MyGame") + expect(result.output).toContain("table Monster") + }, + }) + }) }) From c29d44fcef12b393f82407d6fbd26b0ce8aa979a Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 18 Jan 2026 13:22:39 -0600 Subject: [PATCH 097/104] docs: note untracked files in review --- packages/opencode/src/command/template/review.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/src/command/template/review.txt b/packages/opencode/src/command/template/review.txt index 1ffa0fca0b4..9f6fbfcc3a8 100644 --- a/packages/opencode/src/command/template/review.txt +++ b/packages/opencode/src/command/template/review.txt @@ -13,6 +13,7 @@ Based on the input provided, determine which type of review to perform: 1. **No arguments (default)**: Review all uncommitted changes - Run: `git diff` for unstaged changes - Run: `git diff --cached` for staged changes + - Run: `git status --short` to identify untracked (net new) files 2. **Commit hash** (40-char SHA or short hash): Review that specific commit - Run: `git show $ARGUMENTS` @@ -33,6 +34,7 @@ Use best judgement when processing input. **Diffs alone are not enough.** After getting the diff, read the entire file(s) being modified to understand the full context. Code that looks wrong in isolation may be correct given surrounding logic—and vice versa. - Use the diff to identify which files changed +- Use `git status --short` to identify untracked files, then read their full contents - Read the full file to understand existing patterns, control flow, and error handling - Check for existing style guide or conventions files (CONVENTIONS.md, AGENTS.md, .editorconfig, etc.) From 19cf9344e12891f92662498ee2c9f132ac78480b Mon Sep 17 00:00:00 2001 From: Github Action Date: Sun, 18 Jan 2026 19:24:21 +0000 Subject: [PATCH 098/104] Update node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 16a1c1f398b..5bbdf921bbd 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-4zchRpxzvHnPMcwumgL9yaX0deIXS5IGPp131eYsSvg=", - "aarch64-linux": "sha256-3/BSRsl5pI0Iz3qAFZxIkOehFLZ2Ox9UsbdDHYzqlVg=", - "aarch64-darwin": "sha256-86d/G1q6xiHSSlm+/irXoKLb/yLQbV348uuSrBV70+Q=", - "x86_64-darwin": "sha256-WYaP44PWRGtoG1DIuUJUH4DvuaCuFhlJZ9fPzGsiIfE=" + "x86_64-linux": "sha256-D1VXuKJagfq3mxh8Xs8naHoYNJUJzAM9JLJqpHcItDk=", + "aarch64-linux": "sha256-9wXcg50Sv56Wb2x5NWe15olNGE/uMiDkmGRmqPoeW1U=", + "aarch64-darwin": "sha256-i5eTTjsNAARwcw69sd6wuse2BKTUi/Vfgo4M28l+RoY=", + "x86_64-darwin": "sha256-oFtQnIzgTS2zcjkhBTnXxYqr20KXdA2I+b908piLs+c=" } } From d841e70d2646d84c31f839e8cf7f94bc9bda66a8 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 18 Jan 2026 14:21:08 -0600 Subject: [PATCH 099/104] fix: bad variants for grok models --- packages/opencode/src/provider/transform.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 79892db4cca..b803bd66ce1 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -325,9 +325,24 @@ export namespace ProviderTransform { const id = model.id.toLowerCase() if (id.includes("deepseek") || id.includes("minimax") || id.includes("glm") || id.includes("mistral")) return {} + // see: https://docs.x.ai/docs/guides/reasoning#control-how-hard-the-model-thinks + if (id.includes("grok") && id.includes("grok-3-mini")) { + if (model.api.npm === "@openrouter/ai-sdk-provider") { + return { + low: { reasoning: { effort: "low" } }, + high: { reasoning: { effort: "high" } }, + } + } + return { + low: { reasoningEffort: "low" }, + high: { reasoningEffort: "high" }, + } + } + if (id.includes("grok")) return {} + switch (model.api.npm) { case "@openrouter/ai-sdk-provider": - if (!model.id.includes("gpt") && !model.id.includes("gemini-3") && !model.id.includes("grok-4")) return {} + if (!model.id.includes("gpt") && !model.id.includes("gemini-3")) return {} return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoning: { effort } }])) // TODO: YOU CANNOT SET max_tokens if this is set!!! From 0d8e706facd193610572f1d5b8ddeba80de0b63a Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 18 Jan 2026 14:44:39 -0600 Subject: [PATCH 100/104] test: fix transfomr test --- .../opencode/test/provider/transform.test.ts | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index dcf16c65cbd..2b8f1872f56 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1140,7 +1140,7 @@ describe("ProviderTransform.variants", () => { expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"]) }) - test("grok-4 returns OPENAI_EFFORTS with reasoning", () => { + test("grok-4 returns empty object", () => { const model = createMockModel({ id: "openrouter/grok-4", providerID: "openrouter", @@ -1151,7 +1151,23 @@ describe("ProviderTransform.variants", () => { }, }) const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"]) + expect(result).toEqual({}) + }) + + test("grok-3-mini returns low and high with reasoning", () => { + const model = createMockModel({ + id: "openrouter/grok-3-mini", + providerID: "openrouter", + api: { + id: "grok-3-mini", + url: "https://openrouter.ai", + npm: "@openrouter/ai-sdk-provider", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "high"]) + expect(result.low).toEqual({ reasoning: { effort: "low" } }) + expect(result.high).toEqual({ reasoning: { effort: "high" } }) }) }) @@ -1210,7 +1226,7 @@ describe("ProviderTransform.variants", () => { }) describe("@ai-sdk/xai", () => { - test("returns WIDELY_SUPPORTED_EFFORTS with reasoningEffort", () => { + test("grok-3 returns empty object", () => { const model = createMockModel({ id: "xai/grok-3", providerID: "xai", @@ -1221,7 +1237,21 @@ describe("ProviderTransform.variants", () => { }, }) const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + expect(result).toEqual({}) + }) + + test("grok-3-mini returns low and high with reasoningEffort", () => { + const model = createMockModel({ + id: "xai/grok-3-mini", + providerID: "xai", + api: { + id: "grok-3-mini", + url: "https://api.x.ai", + npm: "@ai-sdk/xai", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "high"]) expect(result.low).toEqual({ reasoningEffort: "low" }) expect(result.high).toEqual({ reasoningEffort: "high" }) }) From b4d4a1ea7d2e590e3963b36580989404377e4ce4 Mon Sep 17 00:00:00 2001 From: Alan Pogrebinschi Date: Sun, 18 Jan 2026 14:46:04 -0800 Subject: [PATCH 101/104] docs: clarify agent tool access and explore vs general distinction (#9300) --- packages/web/src/content/docs/agents.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index 22bed7f16a4..ea1f779cd37 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -21,7 +21,7 @@ There are two types of agents in OpenCode; primary agents and subagents. ### Primary agents -Primary agents are the main assistants you interact with directly. You can cycle through them using the **Tab** key, or your configured `switch_agent` keybind. These agents handle your main conversation and can access all configured tools. +Primary agents are the main assistants you interact with directly. You can cycle through them using the **Tab** key, or your configured `switch_agent` keybind. These agents handle your main conversation. Tool access is configured via permissions — for example, Build has all tools enabled while Plan is restricted. :::tip You can use the **Tab** key to switch between primary agents during a session. @@ -72,7 +72,7 @@ This agent is useful when you want the LLM to analyze code, suggest changes, or _Mode_: `subagent` -A general-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. Use when searching for keywords or files and you're not confident you'll find the right match in the first few tries. +A general-purpose agent for researching complex questions and executing multi-step tasks. Has full tool access (except todo), so it can make file changes when needed. Use this to run multiple units of work in parallel. --- @@ -80,7 +80,7 @@ A general-purpose agent for researching complex questions, searching for code, a _Mode_: `subagent` -A fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns, search code for keywords, or answer questions about the codebase. +A fast, read-only agent for exploring codebases. Cannot modify files. Use this when you need to quickly find files by patterns, search code for keywords, or answer questions about the codebase. --- From e81bb86795c062dae736568c9c4a4426e8fe9474 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Mon, 19 Jan 2026 09:27:30 +1000 Subject: [PATCH 102/104] fix: Windows evaluating text on copy (#9293) --- .../src/cli/cmd/tui/util/clipboard.ts | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 2526f41714c..0e287fbc41a 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -125,9 +125,25 @@ export namespace Clipboard { if (os === "win32") { console.log("clipboard: using powershell") return async (text: string) => { - // need to escape backticks because powershell uses them as escape code - const escaped = text.replace(/"/g, '""').replace(/`/g, "``") - await $`powershell -NonInteractive -NoProfile -Command "Set-Clipboard -Value \"${escaped}\""`.nothrow().quiet() + // Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.) + const proc = Bun.spawn( + [ + "powershell.exe", + "-NonInteractive", + "-NoProfile", + "-Command", + "[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())", + ], + { + stdin: "pipe", + stdout: "ignore", + stderr: "ignore", + }, + ) + + proc.stdin.write(text) + proc.stdin.end() + await proc.exited.catch(() => {}) } } From bee2f654090f92f607fbf4f7d1ff669ae76ede39 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 18 Jan 2026 19:18:58 -0500 Subject: [PATCH 103/104] zen: fix checkout link for black users --- packages/console/core/src/billing.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/console/core/src/billing.ts b/packages/console/core/src/billing.ts index f052e6fc6fe..36e8a76b79d 100644 --- a/packages/console/core/src/billing.ts +++ b/packages/console/core/src/billing.ts @@ -218,6 +218,7 @@ export namespace Billing { customer: customer.customerID, customer_update: { name: "auto", + address: "auto", }, } : { From b0d33eb0863a0847e055404b7f510a26fb363e3d Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 19 Jan 2026 03:00:54 +0000 Subject: [PATCH 104/104] fix: coalesce fragmented paste events over SSH Some terminals (e.g., MobaXterm) fragment large pastes into multiple bracketed paste sequences, causing premature submit when newlines trigger the submit handler before the full paste is received. This adds paste coalescing with a 150ms debounce to buffer consecutive paste events and process them as a single paste operation. Submit is blocked while paste coalescing is active to prevent partial submissions. --- .../cli/cmd/tui/component/prompt/index.tsx | 145 +++++++++++------- 1 file changed, 87 insertions(+), 58 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 730da20c265..cfd3b95dab6 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -59,6 +59,20 @@ export function Prompt(props: PromptProps) { let anchor: BoxRenderable let autocomplete: AutocompleteRef + // Paste coalescing: buffer rapid consecutive paste events (e.g., from MobaXterm + // which fragments large pastes into multiple bracketed paste sequences) + const pasteBuffer: { chunks: string[]; timer: Timer | null } = { + chunks: [], + timer: null, + } + const [isPasting, setIsPasting] = createSignal(false) + const PASTE_DEBOUNCE_MS = 150 + + // Cleanup paste timer on unmount + onCleanup(() => { + if (pasteBuffer.timer) clearTimeout(pasteBuffer.timer) + }) + const keybind = useKeybind() const local = useLocal() const sdk = useSDK() @@ -489,6 +503,7 @@ export function Prompt(props: PromptProps) { async function submit() { if (props.disabled) return if (autocomplete?.visible) return + if (isPasting()) return // Block submit during paste coalescing if (!store.prompt.input) return const trimmed = store.prompt.input.trim() if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") { @@ -691,6 +706,57 @@ export function Prompt(props: PromptProps) { return } + // Process a coalesced paste (called after debounce timer expires) + async function processCoalescedPaste(pastedContent: string) { + if (!pastedContent) { + command.trigger("prompt.paste") + return + } + + // Check if pasted content is a file path + const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ") + const isUrl = /^(https?):\/\//.test(filepath) + if (!isUrl) { + try { + const file = Bun.file(filepath) + if (file.type === "image/svg+xml") { + const content = await file.text().catch(() => {}) + if (content) { + pasteText(content, `[SVG: ${file.name ?? "image"}]`) + return + } + } + if (file.type.startsWith("image/")) { + const content = await file + .arrayBuffer() + .then((buffer) => Buffer.from(buffer).toString("base64")) + .catch(() => {}) + if (content) { + await pasteImage({ + filename: file.name, + mime: file.type, + content, + }) + return + } + } + } catch {} + } + + const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 + if ((lineCount >= 3 || pastedContent.length > 150) && !sync.data.config.experimental?.disable_paste_summary) { + pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) + return + } + + input.insertText(pastedContent) + setTimeout(() => { + input.getLayoutNode().markDirty() + input.gotoBufferEnd() + renderer.requestRender() + }, 0) + } + const highlight = createMemo(() => { if (keybind.leader) return theme.border if (store.mode === "shell") return theme.primary @@ -856,71 +922,34 @@ export function Prompt(props: PromptProps) { } }} onSubmit={submit} - onPaste={async (event: PasteEvent) => { - if (props.disabled) { - event.preventDefault() - return - } + onPaste={(event: PasteEvent) => { + event.preventDefault() + if (props.disabled) return // Normalize line endings at the boundary // Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste // Replace CRLF first, then any remaining CR const normalizedText = event.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") - const pastedContent = normalizedText.trim() - if (!pastedContent) { - command.trigger("prompt.paste") - return - } - // trim ' from the beginning and end of the pasted content. just - // ' and nothing else - const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ") - const isUrl = /^(https?):\/\//.test(filepath) - if (!isUrl) { + // Buffer the paste content for coalescing + // Some terminals (e.g., MobaXterm) fragment large pastes into multiple + // bracketed paste sequences, which would otherwise trigger premature submit + pasteBuffer.chunks.push(normalizedText) + setIsPasting(true) + + // Reset the debounce timer + if (pasteBuffer.timer) clearTimeout(pasteBuffer.timer) + pasteBuffer.timer = setTimeout(async () => { + const coalesced = pasteBuffer.chunks.join("").trim() + pasteBuffer.chunks = [] + pasteBuffer.timer = null try { - const file = Bun.file(filepath) - // Handle SVG as raw text content, not as base64 image - if (file.type === "image/svg+xml") { - event.preventDefault() - const content = await file.text().catch(() => {}) - if (content) { - pasteText(content, `[SVG: ${file.name ?? "image"}]`) - return - } - } - if (file.type.startsWith("image/")) { - event.preventDefault() - const content = await file - .arrayBuffer() - .then((buffer) => Buffer.from(buffer).toString("base64")) - .catch(() => {}) - if (content) { - await pasteImage({ - filename: file.name, - mime: file.type, - content, - }) - return - } - } - } catch {} - } - - const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 - if ( - (lineCount >= 3 || pastedContent.length > 150) && - !sync.data.config.experimental?.disable_paste_summary - ) { - event.preventDefault() - pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) - return - } - - // Force layout update and render for the pasted content - setTimeout(() => { - input.getLayoutNode().markDirty() - renderer.requestRender() - }, 0) + await processCoalescedPaste(coalesced) + } finally { + // Only clear isPasting if no new paste arrived during processing + if (!pasteBuffer.timer) setIsPasting(false) + } + }, PASTE_DEBOUNCE_MS) }} ref={(r: TextareaRenderable) => { input = r