From 4e14f79511728d04329da664b747dd0b359cf931 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 7 May 2026 23:17:43 -0500 Subject: [PATCH 1/9] fix: tweaks to transform logic for anthropic and bedrock (#26276) --- packages/opencode/src/provider/transform.ts | 18 +- packages/opencode/src/session/message-v2.ts | 39 ++--- .../opencode/test/session/message-v2.test.ts | 157 +++++++++++++++++- 3 files changed, 190 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 210c574d4f..3f52f6a2aa 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -135,9 +135,16 @@ function normalizeMessages( } if (!Array.isArray(msg.content)) return msg const filtered = msg.content.filter((part) => { - if (part.type === "text" || part.type === "reasoning") { + if (part.type === "text") { return part.text !== "" } + if (part.type === "reasoning") { + return ( + part.text.trim().length > 0 || + part.providerOptions?.anthropic?.signature != null || + part.providerOptions?.anthropic?.redactedData != null + ) + } return true }) if (filtered.length === 0) return undefined @@ -156,9 +163,16 @@ function normalizeMessages( } if (!Array.isArray(msg.content)) return msg const filtered = msg.content.filter((part) => { - if (part.type === "text" || part.type === "reasoning") { + if (part.type === "text") { return part.text !== "" } + if (part.type === "reasoning") { + return ( + part.text.trim().length > 0 || + part.providerOptions?.bedrock?.signature != null || + part.providerOptions?.bedrock?.redactedData != null + ) + } return true }) if (filtered.length === 0) return undefined diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index ed09262d0e..2930dbaeb3 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -35,7 +35,7 @@ interface FetchDecompressionError extends Error { path: string } -export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:" +export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached media from tool result:" export { isMedia } export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {}) @@ -734,25 +734,25 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( const result: UIMessage[] = [] const toolNames = new Set() // Track media from tool results that need to be injected as user messages - // for providers that don't support media in tool results. + // for providers that don't support that media type in tool results. // // OpenAI-compatible APIs only support string content in tool results, so we need - // to extract media and inject as user messages. Other SDKs (anthropic, google, - // bedrock) handle type: "content" with media parts natively. + // to extract media and inject as user messages. Some SDKs only support a subset + // of media in tool results; e.g. Bedrock supports images but not PDFs there. // - // Only apply this workaround if the model actually supports image input - - // otherwise there's no point extracting images. - const supportsMediaInToolResults = (() => { + // Only apply this workaround if the model actually supports that media input - + // otherwise unsupportedParts() will turn it into a user-visible error. + const supportsMediaInToolResult = (attachment: { mime: string }) => { if (model.api.npm === "@ai-sdk/anthropic") return true if (model.api.npm === "@ai-sdk/openai") return true - if (model.api.npm === "@ai-sdk/amazon-bedrock") return true + if (model.api.npm === "@ai-sdk/amazon-bedrock") return attachment.mime.startsWith("image/") if (model.api.npm === "@ai-sdk/google-vertex/anthropic") return true if (model.api.npm === "@ai-sdk/google") { const id = model.api.id.toLowerCase() return id.includes("gemini-3") && !id.includes("gemini-2") } return false - })() + } const toModelOutput = (options: { toolCallId: string; input: unknown; output: unknown }) => { const output = options.output @@ -797,9 +797,9 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( role: "user", parts: [], } - result.push(userMessage) for (const part of msg.parts) { - if (part.type === "text" && !part.ignored) + // User message parts should never be empty + if (part.type === "text" && !part.ignored && part.text !== "") userMessage.parts.push({ type: "text", text: part.text, @@ -834,11 +834,12 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( }) } } + if (userMessage.parts.length > 0) result.push(userMessage) } if (msg.info.role === "assistant") { const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}` - const media: Array<{ mime: string; url: string }> = [] + const media: Array<{ mime: string; url: string; filename?: string }> = [] if ( msg.info.error && @@ -864,11 +865,10 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( // a proxy, or a lower-level library, but preserving a non-empty separator // here is the only safe replay point we have. // Use a single space so the separator survives replay without changing - // the neighboring signed reasoning blocks. Bedrock-hosted Claude stores - // the same signature under the bedrock metadata namespace. + // the neighboring signed reasoning blocks. const hasSignedReasoning = msg.parts.some((part) => { if (part.type !== "reasoning") return false - return part.metadata?.anthropic?.signature != null || part.metadata?.bedrock?.signature != null + return part.metadata?.anthropic?.signature != null }) for (const part of msg.parts) { if (part.type === "text") { @@ -894,11 +894,11 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( // For providers that don't support media in tool results, extract media files // (images, PDFs) to be sent as a separate user message const mediaAttachments = attachments.filter((a) => isMedia(a.mime)) - const nonMediaAttachments = attachments.filter((a) => !isMedia(a.mime)) - if (!supportsMediaInToolResults && mediaAttachments.length > 0) { - media.push(...mediaAttachments) + const extractedMedia = mediaAttachments.filter((a) => !supportsMediaInToolResult(a)) + if (extractedMedia.length > 0) { + media.push(...extractedMedia) } - const finalAttachments = supportsMediaInToolResults ? attachments : nonMediaAttachments + const finalAttachments = attachments.filter((a) => !isMedia(a.mime) || supportsMediaInToolResult(a)) const output = finalAttachments.length > 0 @@ -988,6 +988,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( type: "file" as const, url: attachment.url, mediaType: attachment.mime, + filename: attachment.filename, })), ], }) diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 999b61b48e..d9c71f8c07 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -155,6 +155,54 @@ describe("session.message-v2.toModelMessage", () => { expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([]) }) + test("filters out user messages with only empty text parts", async () => { + const messageID = "m-user" + + const input: MessageV2.WithParts[] = [ + { + info: userInfo(messageID), + parts: [ + { + ...basePart(messageID, "p1"), + type: "text", + text: "", + }, + ] as MessageV2.Part[], + }, + ] + + expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([]) + }) + + test("filters empty user text parts while keeping non-empty parts", async () => { + const messageID = "m-user" + + const input: MessageV2.WithParts[] = [ + { + info: userInfo(messageID), + parts: [ + { + ...basePart(messageID, "p1"), + type: "text", + text: "", + }, + { + ...basePart(messageID, "p2"), + type: "text", + text: "hello", + }, + ] as MessageV2.Part[], + }, + ] + + expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "hello" }], + }, + ]) + }) + test("includes synthetic text parts", async () => { const messageID = "m-user" @@ -443,6 +491,108 @@ describe("session.message-v2.toModelMessage", () => { }) }) + test("moves bedrock pdf tool-result media into a separate user message", async () => { + const bedrockModel: Provider.Model = { + ...model, + id: ModelID.make("amazon-bedrock/anthropic.claude-sonnet-4-6"), + providerID: ProviderID.make("amazon-bedrock"), + api: { + id: "anthropic.claude-sonnet-4-6", + url: "https://bedrock-runtime.us-east-1.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + capabilities: { + ...model.capabilities, + attachment: true, + input: { + ...model.capabilities.input, + image: true, + pdf: true, + }, + }, + } + const pdf = Buffer.from("%PDF-1.4\n").toString("base64") + const userID = "m-user-bedrock-pdf" + const assistantID = "m-assistant-bedrock-pdf" + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1-bedrock-pdf"), + type: "text", + text: "run tool", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, userID), + parts: [ + { + ...basePart(assistantID, "a1-bedrock-pdf"), + type: "tool", + callID: "call-bedrock-pdf-1", + tool: "read", + state: { + status: "completed", + input: { filePath: "/tmp/example.pdf" }, + output: "PDF read successfully", + title: "Read", + metadata: {}, + time: { start: 0, end: 1 }, + attachments: [ + { + ...basePart(assistantID, "file-bedrock-pdf-1"), + type: "file", + mime: "application/pdf", + filename: "example.pdf", + url: `data:application/pdf;base64,${pdf}`, + }, + ], + }, + }, + ] as MessageV2.Part[], + }, + ] + + expect(await MessageV2.toModelMessages(input, bedrockModel)).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "run tool" }], + }, + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-bedrock-pdf-1", + toolName: "read", + input: { filePath: "/tmp/example.pdf" }, + providerExecuted: undefined, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-bedrock-pdf-1", + toolName: "read", + output: { type: "text", value: "PDF read successfully" }, + }, + ], + }, + { + role: "user", + content: [ + { type: "text", text: "Attached media from tool result:" }, + { type: "file", mediaType: "application/pdf", filename: "example.pdf", data: `data:application/pdf;base64,${pdf}` }, + ], + }, + ]) + }) + test("omits provider metadata when assistant model differs", async () => { const userID = "m-user" const assistantID = "m-assistant" @@ -1134,8 +1284,9 @@ describe("session.message-v2.toModelMessage", () => { expect((result[1].content as any[]).find((p) => p.type === "text").text).toBe("the answer") }) - test("substitutes space for empty text when reasoning signature is under 'bedrock' namespace", async () => { - // AWS Bedrock hosts Anthropic Claude but stores signatures under metadata.bedrock + test("leaves empty text alone when reasoning signature is under 'bedrock' namespace", async () => { + // Bedrock signed reasoning is preserved as reasoning metadata, but unlike the + // direct Anthropic path we do not preserve empty text separators for Bedrock. const assistantID = "m-assistant-bedrock" const input: MessageV2.WithParts[] = [ { @@ -1157,7 +1308,7 @@ describe("session.message-v2.toModelMessage", () => { expect(result).toHaveLength(1) const texts = (result[0].content as any[]).filter((p) => p.type === "text") - expect(texts.map((t) => t.text)).toStrictEqual([" ", "answer"]) + expect(texts.map((t) => t.text)).toStrictEqual(["", "answer"]) }) test("leaves empty text alone when reasoning has no Anthropic signature", async () => { From 9c88235121eda5f5afd2dd806fbc6e6d8eef664d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 8 May 2026 04:18:54 +0000 Subject: [PATCH 2/9] chore: generate --- packages/opencode/test/session/message-v2.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index d9c71f8c07..08629f5b1b 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -587,7 +587,12 @@ describe("session.message-v2.toModelMessage", () => { role: "user", content: [ { type: "text", text: "Attached media from tool result:" }, - { type: "file", mediaType: "application/pdf", filename: "example.pdf", data: `data:application/pdf;base64,${pdf}` }, + { + type: "file", + mediaType: "application/pdf", + filename: "example.pdf", + data: `data:application/pdf;base64,${pdf}`, + }, ], }, ]) From 30868f52ea997ada6ac452e47ec00fb5ee59302c Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 8 May 2026 00:26:08 -0400 Subject: [PATCH 3/9] go: update rate limit error copy --- packages/console/app/src/i18n/ar.ts | 8 ++++++-- packages/console/app/src/i18n/br.ts | 8 ++++++-- packages/console/app/src/i18n/da.ts | 8 ++++++-- packages/console/app/src/i18n/de.ts | 8 ++++++-- packages/console/app/src/i18n/en.ts | 8 ++++++-- packages/console/app/src/i18n/es.ts | 8 ++++++-- packages/console/app/src/i18n/fr.ts | 8 ++++++-- packages/console/app/src/i18n/it.ts | 8 ++++++-- packages/console/app/src/i18n/ja.ts | 8 ++++++-- packages/console/app/src/i18n/ko.ts | 8 ++++++-- packages/console/app/src/i18n/no.ts | 8 ++++++-- packages/console/app/src/i18n/pl.ts | 8 ++++++-- packages/console/app/src/i18n/ru.ts | 8 ++++++-- packages/console/app/src/i18n/th.ts | 8 ++++++-- packages/console/app/src/i18n/tr.ts | 8 ++++++-- packages/console/app/src/i18n/zh.ts | 7 ++++++- packages/console/app/src/i18n/zht.ts | 7 ++++++- .../console/app/src/routes/zen/util/handler.ts | 16 +++++++++++++--- 18 files changed, 115 insertions(+), 35 deletions(-) diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts index 12ec7f1fbd..f413b5572f 100644 --- a/packages/console/app/src/i18n/ar.ts +++ b/packages/console/app/src/i18n/ar.ts @@ -355,8 +355,12 @@ export const dict = { "zen.api.error.missingApiKey": "مفتاح API مفقود.", "zen.api.error.invalidApiKey": "مفتاح API غير صالح.", "zen.api.error.subscriptionQuotaExceeded": "تم تجاوز حصة الاشتراك. أعد المحاولة خلال {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "تم تجاوز حصة الاشتراك. يمكنك الاستمرار في استخدام النماذج المجانية.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "تم الوصول إلى حد الاستخدام لمدة 5 ساعات. تتم إعادة التعيين خلال {{retryIn}}. لمواصلة استخدام هذا النموذج الآن، فعّل الاستخدام من رصيدك المتاح: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "تم الوصول إلى حد الاستخدام الأسبوعي. تتم إعادة التعيين خلال {{retryIn}}. لمواصلة استخدام هذا النموذج الآن، فعّل الاستخدام من رصيدك المتاح: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "تم الوصول إلى حد الاستخدام الشهري. تتم إعادة التعيين خلال {{retryIn}}. لمواصلة استخدام هذا النموذج الآن، فعّل الاستخدام من رصيدك المتاح: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "لا توجد طريقة دفع. أضف طريقة دفع هنا: {{billingUrl}}", "zen.api.error.insufficientBalance": "رصيد غير كاف. إدارة فواتيرك هنا: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts index 0a6d8f153e..8466acc5fd 100644 --- a/packages/console/app/src/i18n/br.ts +++ b/packages/console/app/src/i18n/br.ts @@ -363,8 +363,12 @@ export const dict = { "zen.api.error.missingApiKey": "Chave de API ausente.", "zen.api.error.invalidApiKey": "Chave de API inválida.", "zen.api.error.subscriptionQuotaExceeded": "Cota de assinatura excedida. Tente novamente em {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Cota de assinatura excedida. Você pode continuar usando modelos gratuitos.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "Limite de uso de 5 horas atingido. Será reiniciado em {{retryIn}}. Para continuar usando este modelo agora, habilite o uso a partir do seu saldo disponível: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Limite de uso semanal atingido. Será reiniciado em {{retryIn}}. Para continuar usando este modelo agora, habilite o uso a partir do seu saldo disponível: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Limite de uso mensal atingido. Será reiniciado em {{retryIn}}. Para continuar usando este modelo agora, habilite o uso a partir do seu saldo disponível: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Nenhuma forma de pagamento. Adicione uma forma de pagamento aqui: {{billingUrl}}", "zen.api.error.insufficientBalance": "Saldo insuficiente. Gerencie seu faturamento aqui: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts index 15e7151b67..9338e3add5 100644 --- a/packages/console/app/src/i18n/da.ts +++ b/packages/console/app/src/i18n/da.ts @@ -359,8 +359,12 @@ export const dict = { "zen.api.error.missingApiKey": "Manglende API-nøgle.", "zen.api.error.invalidApiKey": "Ugyldig API-nøgle.", "zen.api.error.subscriptionQuotaExceeded": "Abonnementskvote overskredet. Prøv igen om {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Abonnementskvote overskredet. Du kan fortsætte med at bruge gratis modeller.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "Forbrugsgrænsen for 5 timer er nået. Nulstilles om {{retryIn}}. For at fortsætte med at bruge denne model nu, aktivér forbrug fra din tilgængelige saldo: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Ugentlig forbrugsgrænse er nået. Nulstilles om {{retryIn}}. For at fortsætte med at bruge denne model nu, aktivér forbrug fra din tilgængelige saldo: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Månedlig forbrugsgrænse er nået. Nulstilles om {{retryIn}}. For at fortsætte med at bruge denne model nu, aktivér forbrug fra din tilgængelige saldo: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Ingen betalingsmetode. Tilføj en betalingsmetode her: {{billingUrl}}", "zen.api.error.insufficientBalance": "Utilstrækkelig saldo. Administrer din fakturering her: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts index 0efcce78bf..7a2d3e91b4 100644 --- a/packages/console/app/src/i18n/de.ts +++ b/packages/console/app/src/i18n/de.ts @@ -362,8 +362,12 @@ export const dict = { "zen.api.error.missingApiKey": "Fehlender API-Key.", "zen.api.error.invalidApiKey": "Ungültiger API-Key.", "zen.api.error.subscriptionQuotaExceeded": "Abonnement-Quote überschritten. Erneuter Versuch in {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Abonnement-Quote überschritten. Du kannst weiterhin kostenlose Modelle nutzen.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "5-Stunden-Nutzungslimit erreicht. Wird in {{retryIn}} zurückgesetzt. Um dieses Modell jetzt weiter zu nutzen, aktiviere die Nutzung über dein verfügbares Guthaben: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Wöchentliches Nutzungslimit erreicht. Wird in {{retryIn}} zurückgesetzt. Um dieses Modell jetzt weiter zu nutzen, aktiviere die Nutzung über dein verfügbares Guthaben: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Monatliches Nutzungslimit erreicht. Wird in {{retryIn}} zurückgesetzt. Um dieses Modell jetzt weiter zu nutzen, aktiviere die Nutzung über dein verfügbares Guthaben: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Keine Zahlungsmethode. Füge hier eine Zahlungsmethode hinzu: {{billingUrl}}", "zen.api.error.insufficientBalance": "Unzureichendes Guthaben. Verwalte deine Abrechnung hier: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index f2cf3c14a4..b7ef397be6 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -355,8 +355,12 @@ export const dict = { "zen.api.error.missingApiKey": "Missing API key.", "zen.api.error.invalidApiKey": "Invalid API key.", "zen.api.error.subscriptionQuotaExceeded": "Subscription quota exceeded. Retry in {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Subscription quota exceeded. You can continue using free models.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "5-hour usage limit reached. Resets in {{retryIn}}. To continue using this model now, enable usage from your available balance: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Weekly usage limit reached. Resets in {{retryIn}}. To continue using this model now, enable usage from your available balance: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Monthly usage limit reached. Resets in {{retryIn}}. To continue using this model now, enable usage from your available balance: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "No payment method. Add a payment method here: {{billingUrl}}", "zen.api.error.insufficientBalance": "Insufficient balance. Manage your billing here: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts index 5614a8c7ad..f6347d3b52 100644 --- a/packages/console/app/src/i18n/es.ts +++ b/packages/console/app/src/i18n/es.ts @@ -363,8 +363,12 @@ export const dict = { "zen.api.error.missingApiKey": "Falta la clave API.", "zen.api.error.invalidApiKey": "Clave API inválida.", "zen.api.error.subscriptionQuotaExceeded": "Cuota de suscripción excedida. Reintenta en {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Cuota de suscripción excedida. Puedes continuar usando modelos gratuitos.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "Límite de uso de 5 horas alcanzado. Se restablece en {{retryIn}}. Para seguir usando este modelo ahora, habilita el uso desde tu saldo disponible: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Límite de uso semanal alcanzado. Se restablece en {{retryIn}}. Para seguir usando este modelo ahora, habilita el uso desde tu saldo disponible: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Límite de uso mensual alcanzado. Se restablece en {{retryIn}}. Para seguir usando este modelo ahora, habilita el uso desde tu saldo disponible: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Sin método de pago. Añade un método de pago aquí: {{billingUrl}}", "zen.api.error.insufficientBalance": "Saldo insuficiente. Gestiona tu facturación aquí: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts index 390025d275..5d1cd0fab7 100644 --- a/packages/console/app/src/i18n/fr.ts +++ b/packages/console/app/src/i18n/fr.ts @@ -363,8 +363,12 @@ export const dict = { "zen.api.error.missingApiKey": "Clé API manquante.", "zen.api.error.invalidApiKey": "Clé API invalide.", "zen.api.error.subscriptionQuotaExceeded": "Quota d'abonnement dépassé. Réessayez dans {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Quota d'abonnement dépassé. Vous pouvez continuer à utiliser les modèles gratuits.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "Limite d'utilisation sur 5 heures atteinte. Réinitialisation dans {{retryIn}}. Pour continuer à utiliser ce modèle dès maintenant, activez l'utilisation depuis votre solde disponible : {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Limite d'utilisation hebdomadaire atteinte. Réinitialisation dans {{retryIn}}. Pour continuer à utiliser ce modèle dès maintenant, activez l'utilisation depuis votre solde disponible : {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Limite d'utilisation mensuelle atteinte. Réinitialisation dans {{retryIn}}. Pour continuer à utiliser ce modèle dès maintenant, activez l'utilisation depuis votre solde disponible : {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Aucune méthode de paiement. Ajoutez une méthode de paiement ici : {{billingUrl}}", "zen.api.error.insufficientBalance": "Solde insuffisant. Gérez votre facturation ici : {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts index 3737186996..07da9434eb 100644 --- a/packages/console/app/src/i18n/it.ts +++ b/packages/console/app/src/i18n/it.ts @@ -359,8 +359,12 @@ export const dict = { "zen.api.error.missingApiKey": "Chiave API mancante.", "zen.api.error.invalidApiKey": "Chiave API non valida.", "zen.api.error.subscriptionQuotaExceeded": "Quota dell'abbonamento superata. Riprova tra {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Quota dell'abbonamento superata. Puoi continuare a utilizzare modelli gratuiti.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "Limite di utilizzo di 5 ore raggiunto. Si reimposta tra {{retryIn}}. Per continuare a usare questo modello ora, abilita l'utilizzo dal tuo saldo disponibile: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Limite di utilizzo settimanale raggiunto. Si reimposta tra {{retryIn}}. Per continuare a usare questo modello ora, abilita l'utilizzo dal tuo saldo disponibile: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Limite di utilizzo mensile raggiunto. Si reimposta tra {{retryIn}}. Per continuare a usare questo modello ora, abilita l'utilizzo dal tuo saldo disponibile: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Nessun metodo di pagamento. Aggiungi un metodo di pagamento qui: {{billingUrl}}", "zen.api.error.insufficientBalance": "Saldo insufficiente. Gestisci la tua fatturazione qui: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts index 66f3c4a89d..975728fe7e 100644 --- a/packages/console/app/src/i18n/ja.ts +++ b/packages/console/app/src/i18n/ja.ts @@ -360,8 +360,12 @@ export const dict = { "zen.api.error.invalidApiKey": "無効なAPIキーです。", "zen.api.error.subscriptionQuotaExceeded": "サブスクリプションの制限を超えました。{{retryIn}} 後に再試行してください。", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "サブスクリプションの制限を超えました。無料モデルは引き続きご利用いただけます。", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "5時間の利用上限に達しました。{{retryIn}} 後にリセットされます。今すぐこのモデルの利用を続けるには、利用可能な残高からの利用を有効化してください: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "週間の利用上限に達しました。{{retryIn}} 後にリセットされます。今すぐこのモデルの利用を続けるには、利用可能な残高からの利用を有効化してください: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "月間の利用上限に達しました。{{retryIn}} 後にリセットされます。今すぐこのモデルの利用を続けるには、利用可能な残高からの利用を有効化してください: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "お支払い方法がありません。こちらからお支払い方法を追加してください: {{billingUrl}}", "zen.api.error.insufficientBalance": "残高が不足しています。こちらから請求を管理してください: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts index 04482d35f6..293c3eb7d9 100644 --- a/packages/console/app/src/i18n/ko.ts +++ b/packages/console/app/src/i18n/ko.ts @@ -354,8 +354,12 @@ export const dict = { "zen.api.error.missingApiKey": "API 키가 누락되었습니다.", "zen.api.error.invalidApiKey": "유효하지 않은 API 키입니다.", "zen.api.error.subscriptionQuotaExceeded": "구독 할당량을 초과했습니다. {{retryIn}} 후 다시 시도해 주세요.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "구독 할당량을 초과했습니다. 무료 모델은 계속 사용할 수 있습니다.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "5시간 사용 한도에 도달했습니다. {{retryIn}} 후 초기화됩니다. 이 모델을 지금 계속 사용하려면 사용 가능한 잔액에서 사용을 활성화하세요: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "주간 사용 한도에 도달했습니다. {{retryIn}} 후 초기화됩니다. 이 모델을 지금 계속 사용하려면 사용 가능한 잔액에서 사용을 활성화하세요: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "월간 사용 한도에 도달했습니다. {{retryIn}} 후 초기화됩니다. 이 모델을 지금 계속 사용하려면 사용 가능한 잔액에서 사용을 활성화하세요: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "결제 수단이 없습니다. 결제 수단을 추가하세요: {{billingUrl}}", "zen.api.error.insufficientBalance": "잔액이 부족합니다. 결제 관리를 여기서 하세요: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts index 31200d3edd..27b5522e32 100644 --- a/packages/console/app/src/i18n/no.ts +++ b/packages/console/app/src/i18n/no.ts @@ -360,8 +360,12 @@ export const dict = { "zen.api.error.missingApiKey": "Mangler API-nøkkel.", "zen.api.error.invalidApiKey": "Ugyldig API-nøkkel.", "zen.api.error.subscriptionQuotaExceeded": "Abonnementskvote overskredet. Prøv igjen om {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Abonnementskvote overskredet. Du kan fortsette å bruke gratis modeller.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "5-timers bruksgrense nådd. Tilbakestilles om {{retryIn}}. For å fortsette å bruke denne modellen nå, aktiver bruk fra din tilgjengelige saldo: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Ukentlig bruksgrense nådd. Tilbakestilles om {{retryIn}}. For å fortsette å bruke denne modellen nå, aktiver bruk fra din tilgjengelige saldo: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Månedlig bruksgrense nådd. Tilbakestilles om {{retryIn}}. For å fortsette å bruke denne modellen nå, aktiver bruk fra din tilgjengelige saldo: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Ingen betalingsmetode. Legg til en betalingsmetode her: {{billingUrl}}", "zen.api.error.insufficientBalance": "Utilstrekkelig saldo. Administrer faktureringen din her: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts index 50d904bc56..7f8c849156 100644 --- a/packages/console/app/src/i18n/pl.ts +++ b/packages/console/app/src/i18n/pl.ts @@ -361,8 +361,12 @@ export const dict = { "zen.api.error.missingApiKey": "Brak klucza API.", "zen.api.error.invalidApiKey": "Nieprawidłowy klucz API.", "zen.api.error.subscriptionQuotaExceeded": "Przekroczono limit subskrypcji. Spróbuj ponownie za {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Przekroczono limit subskrypcji. Możesz kontynuować korzystanie z darmowych modeli.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "Osiągnięto 5-godzinny limit użycia. Resetuje się za {{retryIn}}. Aby nadal korzystać z tego modelu, włącz użycie z dostępnego salda: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Osiągnięto tygodniowy limit użycia. Resetuje się za {{retryIn}}. Aby nadal korzystać z tego modelu, włącz użycie z dostępnego salda: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Osiągnięto miesięczny limit użycia. Resetuje się za {{retryIn}}. Aby nadal korzystać z tego modelu, włącz użycie z dostępnego salda: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Brak metody płatności. Dodaj metodę płatności tutaj: {{billingUrl}}", "zen.api.error.insufficientBalance": "Niewystarczające saldo. Zarządzaj swoimi płatnościami tutaj: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts index 651309fc95..4ac54c2ac0 100644 --- a/packages/console/app/src/i18n/ru.ts +++ b/packages/console/app/src/i18n/ru.ts @@ -365,8 +365,12 @@ export const dict = { "zen.api.error.missingApiKey": "Отсутствует API ключ.", "zen.api.error.invalidApiKey": "Неверный API ключ.", "zen.api.error.subscriptionQuotaExceeded": "Квота подписки превышена. Повторите попытку через {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Квота подписки превышена. Вы можете продолжить использовать бесплатные модели.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "Достигнут лимит использования за 5 часов. Сбросится через {{retryIn}}. Чтобы продолжить использовать эту модель сейчас, включите оплату с доступного баланса: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Достигнут недельный лимит использования. Сбросится через {{retryIn}}. Чтобы продолжить использовать эту модель сейчас, включите оплату с доступного баланса: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Достигнут месячный лимит использования. Сбросится через {{retryIn}}. Чтобы продолжить использовать эту модель сейчас, включите оплату с доступного баланса: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Нет способа оплаты. Добавьте способ оплаты здесь: {{billingUrl}}", "zen.api.error.insufficientBalance": "Недостаточно средств. Управляйте оплатой здесь: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts index 42c9e455fd..280b9d9fa8 100644 --- a/packages/console/app/src/i18n/th.ts +++ b/packages/console/app/src/i18n/th.ts @@ -356,8 +356,12 @@ export const dict = { "zen.api.error.missingApiKey": "ไม่มี API key", "zen.api.error.invalidApiKey": "API key ไม่ถูกต้อง", "zen.api.error.subscriptionQuotaExceeded": "โควต้าการสมัครสมาชิกเกินขีดจำกัด ลองใหม่ในอีก {{retryIn}}", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "โควต้าการสมัครสมาชิกเกินขีดจำกัด คุณสามารถดำเนินการต่อโดยใช้โมเดลฟรี", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "ถึงขีดจำกัดการใช้งานในรอบ 5 ชั่วโมงแล้ว จะรีเซ็ตในอีก {{retryIn}} หากต้องการใช้โมเดลนี้ต่อทันที ให้เปิดใช้งานจากยอดเงินคงเหลือของคุณ: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "ถึงขีดจำกัดการใช้งานรายสัปดาห์แล้ว จะรีเซ็ตในอีก {{retryIn}} หากต้องการใช้โมเดลนี้ต่อทันที ให้เปิดใช้งานจากยอดเงินคงเหลือของคุณ: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "ถึงขีดจำกัดการใช้งานรายเดือนแล้ว จะรีเซ็ตในอีก {{retryIn}} หากต้องการใช้โมเดลนี้ต่อทันที ให้เปิดใช้งานจากยอดเงินคงเหลือของคุณ: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "ไม่มีวิธีการชำระเงิน เพิ่มวิธีการชำระเงินที่นี่: {{billingUrl}}", "zen.api.error.insufficientBalance": "ยอดเงินคงเหลือไม่เพียงพอ จัดการการเรียกเก็บเงินของคุณที่นี่: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts index 64380db375..a8f449dc47 100644 --- a/packages/console/app/src/i18n/tr.ts +++ b/packages/console/app/src/i18n/tr.ts @@ -363,8 +363,12 @@ export const dict = { "zen.api.error.missingApiKey": "API anahtarı eksik.", "zen.api.error.invalidApiKey": "Geçersiz API anahtarı.", "zen.api.error.subscriptionQuotaExceeded": "Abonelik kotası aşıldı. {{retryIn}} içinde tekrar deneyin.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Abonelik kotası aşıldı. Ücretsiz modelleri kullanmaya devam edebilirsiniz.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "5 saatlik kullanım limitine ulaşıldı. {{retryIn}} içinde sıfırlanır. Bu modeli şimdi kullanmaya devam etmek için kullanılabilir bakiyenizden kullanımı etkinleştirin: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Haftalık kullanım limitine ulaşıldı. {{retryIn}} içinde sıfırlanır. Bu modeli şimdi kullanmaya devam etmek için kullanılabilir bakiyenizden kullanımı etkinleştirin: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Aylık kullanım limitine ulaşıldı. {{retryIn}} içinde sıfırlanır. Bu modeli şimdi kullanmaya devam etmek için kullanılabilir bakiyenizden kullanımı etkinleştirin: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Ödeme yöntemi bulunamadı. Buradan bir ödeme yöntemi ekleyin: {{billingUrl}}", "zen.api.error.insufficientBalance": "Yetersiz bakiye. Faturalandırmanızı buradan yönetin: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index 3b104cca6d..ced0060ca0 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -343,7 +343,12 @@ export const dict = { "zen.api.error.missingApiKey": "缺少 API 密钥。", "zen.api.error.invalidApiKey": "无效的 API 密钥。", "zen.api.error.subscriptionQuotaExceeded": "超出订阅配额。请在 {{retryIn}} 后重试。", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": "超出订阅配额。您可以继续使用免费模型。", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "已达到 5 小时使用限额。将在 {{retryIn}} 后重置。如需立即继续使用该模型,请启用从可用余额扣费:{{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "已达到每周使用限额。将在 {{retryIn}} 后重置。如需立即继续使用该模型,请启用从可用余额扣费:{{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "已达到每月使用限额。将在 {{retryIn}} 后重置。如需立即继续使用该模型,请启用从可用余额扣费:{{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "没有付款方式。请在此处添加付款方式:{{billingUrl}}", "zen.api.error.insufficientBalance": "余额不足。请在此处管理您的计费:{{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts index a4d5512da4..e3e374a329 100644 --- a/packages/console/app/src/i18n/zht.ts +++ b/packages/console/app/src/i18n/zht.ts @@ -343,7 +343,12 @@ export const dict = { "zen.api.error.missingApiKey": "缺少 API 金鑰。", "zen.api.error.invalidApiKey": "無效的 API 金鑰。", "zen.api.error.subscriptionQuotaExceeded": "超出訂閱配額。請在 {{retryIn}} 後重試。", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": "超出訂閱配額。你可以繼續使用免費模型。", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "已達 5 小時使用上限,將在 {{retryIn}} 後重置。若要立即繼續使用此模型,請從可用餘額啟用使用量:{{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "已達每週使用上限,將在 {{retryIn}} 後重置。若要立即繼續使用此模型,請從可用餘額啟用使用量:{{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "已達每月使用上限,將在 {{retryIn}} 後重置。若要立即繼續使用此模型,請從可用餘額啟用使用量:{{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "無付款方式。請在此處新增付款方式:{{billingUrl}}", "zen.api.error.insufficientBalance": "餘額不足。請在此處管理你的帳務:{{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 4b6fe5feb8..278a541610 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -744,6 +744,7 @@ export async function handler( // Validate lite subscription billing if (opts.modelList === "lite" && authInfo.billing.lite && authInfo.lite) { try { + const consoleGoUrl = `https://opencode.ai/workspace/${authInfo.workspaceID}/go` const sub = authInfo.lite const liteData = LiteData.getLimits() @@ -756,7 +757,10 @@ export async function handler( }) if (result.status === "rate-limited") throw new GoUsageLimitError( - t("zen.api.error.subscriptionQuotaExceededUseFreeModels"), + t("zen.api.error.goSubscriptionWeeklyLimitExceeded", { + retryIn: formatRetryTime(result.resetInSec), + consoleGoUrl, + }), authInfo.workspaceID, "weekly", result.resetInSec, @@ -773,7 +777,10 @@ export async function handler( }) if (result.status === "rate-limited") throw new GoUsageLimitError( - t("zen.api.error.subscriptionQuotaExceededUseFreeModels"), + t("zen.api.error.goSubscriptionMonthlyLimitExceeded", { + retryIn: formatRetryTime(result.resetInSec), + consoleGoUrl, + }), authInfo.workspaceID, "monthly", result.resetInSec, @@ -790,7 +797,10 @@ export async function handler( }) if (result.status === "rate-limited") throw new GoUsageLimitError( - t("zen.api.error.subscriptionQuotaExceededUseFreeModels"), + t("zen.api.error.goSubscriptionRollingLimitExceeded", { + retryIn: formatRetryTime(result.resetInSec), + consoleGoUrl, + }), authInfo.workspaceID, "5 hour", result.resetInSec, From dd8bb44d1db520ada12da5efc5611696372d0810 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 8 May 2026 13:34:53 +0800 Subject: [PATCH 4/9] refactor(desktop): use electron-log in shell-env and simplify env merging (#26284) --- packages/desktop/src/main/logging.ts | 6 +++++- packages/desktop/src/main/server.ts | 19 ++++++++----------- packages/desktop/src/main/shell-env.ts | 12 +++++++----- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/packages/desktop/src/main/logging.ts b/packages/desktop/src/main/logging.ts index 1f1c5e54e3..5d373ed27f 100644 --- a/packages/desktop/src/main/logging.ts +++ b/packages/desktop/src/main/logging.ts @@ -1,3 +1,4 @@ +import { MainLogger } from "electron-log" import log from "electron-log/main.js" import { readFileSync, readdirSync, statSync, unlinkSync } from "node:fs" import { dirname, join } from "node:path" @@ -5,11 +6,14 @@ import { dirname, join } from "node:path" const MAX_LOG_AGE_DAYS = 7 const TAIL_LINES = 1000 +let logger: MainLogger +export const getLogger = () => logger + export function initLogging() { log.transports.file.maxSize = 5 * 1024 * 1024 initConsoleTransport() cleanup() - return log + return (logger = log) } export function tail(): string { diff --git a/packages/desktop/src/main/server.ts b/packages/desktop/src/main/server.ts index 635a93578a..909138b89c 100644 --- a/packages/desktop/src/main/server.ts +++ b/packages/desktop/src/main/server.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url" import { app, utilityProcess } from "electron" import type { Details } from "electron" import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants" -import { getUserShell, loadShellEnv, mergeShellEnv } from "./shell-env" +import { getUserShell, loadShellEnv } from "./shell-env" import { getStore } from "./store" import type { SqliteMigrationProgress } from "../preload/types" @@ -57,16 +57,13 @@ export function setWslConfig(config: WslConfig) { export function preferAppEnv(userDataPath: string) { const shell = process.platform === "win32" ? null : getUserShell() - Object.assign( - process.env, - mergeShellEnv(shell ? loadShellEnv(shell) : null, { - ...process.env, - OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", - OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", - OPENCODE_CLIENT: "desktop", - XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath, - }), - ) + Object.assign(process.env, { + ...(shell ? loadShellEnv(shell) : null), + OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", + OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", + OPENCODE_CLIENT: "desktop", + XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath, + }) } export async function spawnLocalServer( diff --git a/packages/desktop/src/main/shell-env.ts b/packages/desktop/src/main/shell-env.ts index f57677323c..8a1ee1f586 100644 --- a/packages/desktop/src/main/shell-env.ts +++ b/packages/desktop/src/main/shell-env.ts @@ -1,5 +1,6 @@ import { spawnSync } from "node:child_process" import { basename } from "node:path" +import { getLogger } from "./logging"; const TIMEOUT = 5_000 @@ -55,28 +56,29 @@ export function isNushell(shell: string) { } export function loadShellEnv(shell: string) { + const logger = getLogger() if (isNushell(shell)) { - console.log(`[server] Skipping shell env probe for nushell: ${shell}`) + logger.log(`[server] Skipping shell env probe for nushell: ${shell}`) return null } const interactive = probe(shell, "-il") if (interactive.type === "Loaded") { - console.log(`[server] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`) + logger.log(`[server] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`) return interactive.value } if (interactive.type === "Timeout") { - console.warn(`[server] Interactive shell env probe timed out: ${shell}`) + logger.log(`[server] Interactive shell env probe timed out: ${shell}`) return null } const login = probe(shell, "-l") if (login.type === "Loaded") { - console.log(`[server] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`) + logger.log(`[server] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`) return login.value } - console.warn(`[server] Falling back to app environment: ${shell}`) + logger.log(`[server] Falling back to app environment: ${shell}`) return null } From cef0c8ac844189872875cae6950fc14ee3b522cb Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 8 May 2026 05:36:06 +0000 Subject: [PATCH 5/9] chore: generate --- packages/desktop/src/main/shell-env.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/desktop/src/main/shell-env.ts b/packages/desktop/src/main/shell-env.ts index 8a1ee1f586..4a65fbf0f7 100644 --- a/packages/desktop/src/main/shell-env.ts +++ b/packages/desktop/src/main/shell-env.ts @@ -1,6 +1,6 @@ import { spawnSync } from "node:child_process" import { basename } from "node:path" -import { getLogger } from "./logging"; +import { getLogger } from "./logging" const TIMEOUT = 5_000 From 6f165e23deae6d3a812af2b0aaf1557d94251a15 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 8 May 2026 15:36:28 +1000 Subject: [PATCH 6/9] perf(ui): defer tool status width measurement (#26282) --- .../ui/src/components/tool-status-title.tsx | 132 +++++++++--------- 1 file changed, 65 insertions(+), 67 deletions(-) diff --git a/packages/ui/src/components/tool-status-title.tsx b/packages/ui/src/components/tool-status-title.tsx index 412d92e3db..5c46593f71 100644 --- a/packages/ui/src/components/tool-status-title.tsx +++ b/packages/ui/src/components/tool-status-title.tsx @@ -1,4 +1,4 @@ -import { Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js" +import { Show, createEffect, createMemo, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { TextShimmer } from "./text-shimmer" @@ -15,10 +15,8 @@ function common(active: string, done: string) { } function contentWidth(el: HTMLSpanElement | undefined) { - if (!el) return 0 - const range = document.createRange() - range.selectNodeContents(el) - return Math.ceil(range.getBoundingClientRect().width) + if (!el) return + return `${Math.ceil(el.getBoundingClientRect().width)}px` } export function ToolStatusTitle(props: { @@ -37,99 +35,99 @@ export function ToolStatusTitle(props: { const doneTail = createMemo(() => (suffix() ? split().done : props.doneText)) const [state, setState] = createStore({ - width: "auto", - ready: false, + active: props.active, + animating: false, + width: undefined as string | undefined, }) const width = () => state.width - const ready = () => state.ready + const active = () => state.active + const animating = () => state.animating let activeRef: HTMLSpanElement | undefined let doneRef: HTMLSpanElement | undefined + let widthRef: HTMLSpanElement | undefined let frame: number | undefined - let readyFrame: number | undefined - - const measure = () => { - const target = props.active ? activeRef : doneRef - const px = contentWidth(target) - if (px > 0) setState("width", `${px}px`) - } - - const schedule = () => { - if (typeof requestAnimationFrame !== "function") { - measure() - return - } - if (frame !== undefined) cancelAnimationFrame(frame) - frame = requestAnimationFrame(() => { - frame = undefined - measure() - }) - } + let finishTimer: ReturnType | undefined const finish = () => { - if (typeof requestAnimationFrame !== "function") { - setState("ready", true) + if (frame !== undefined) cancelAnimationFrame(frame) + if (finishTimer !== undefined) clearTimeout(finishTimer) + frame = undefined + finishTimer = undefined + setState("animating", false) + setState("width", undefined) + } + + const animate = () => { + const first = contentWidth(widthRef) + finish() + setState("animating", true) + setState("active", props.active) + const last = contentWidth(props.active ? activeRef : doneRef) + if (!first || !last) { + finish() return } - if (readyFrame !== undefined) cancelAnimationFrame(readyFrame) - readyFrame = requestAnimationFrame(() => { - readyFrame = undefined - setState("ready", true) + + setState("width", first) + if (first === last) { + finishTimer = setTimeout(finish, 600) + return + } + + frame = requestAnimationFrame(() => { + frame = undefined + setState("width", last) + finishTimer = setTimeout(finish, 600) }) } - createEffect(on([() => props.active, activeTail, doneTail, suffix], () => schedule())) - - onMount(() => { - measure() - const fonts = typeof document !== "undefined" ? document.fonts : undefined - if (!fonts) { - finish() - return - } - void fonts.ready.finally(() => { - measure() - finish() - }) - }) + createEffect(on([() => props.active, activeTail, doneTail], () => animate(), { defer: true })) onCleanup(() => { - if (frame !== undefined) cancelAnimationFrame(frame) - if (readyFrame !== undefined) cancelAnimationFrame(readyFrame) + finish() }) return ( - - - - - - + + + + + + + + + + + } > - + - - - - - - - + + + + + + + + + + + From bb3f14119b25edcf0478757f7c5f9e1a8e664dab Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 8 May 2026 01:51:31 -0400 Subject: [PATCH 7/9] tui: update go upsell copy --- packages/opencode/src/session/retry.ts | 24 ++++++++++---------- packages/opencode/test/session/retry.test.ts | 8 ++++--- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index a4ef5b7a8f..3bccee212d 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -6,7 +6,6 @@ import { iife } from "@/util/iife" export type Err = ReturnType export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go" -export const PAYG_UPSELL_MESSAGE = "Go usage exceeded, enable PAYG" export const GO_UPSELL_URL = "https://opencode.ai/go" export type Retryable = { @@ -83,11 +82,11 @@ export function retryable(error: Err) { if (error.data.responseBody?.includes("GoUsageLimitError")) { const body = parseJSON(error.data.responseBody) const workspace = str(body?.metadata?.workspace) - const limit = str(body?.metadata?.limit) - const resetAt = num(body?.metadata?.resetAt) + const limitName = str(body?.metadata?.limitName) + const retryAfter = num(error.data.responseHeaders?.["retry-after"]) const resetIn = iife(() => { - if (resetAt === undefined) return "" - const seconds = Math.max(0, Math.ceil(resetAt)) + if (retryAfter === undefined) return "" + const seconds = Math.max(0, Math.ceil(retryAfter)) const days = Math.floor(seconds / 86_400) const hours = Math.floor((seconds % 86_400) / 3_600) const minutes = Math.ceil((seconds % 3_600) / 60) @@ -97,16 +96,17 @@ export function retryable(error: Err) { if (hours > 0) return minutes > 0 ? `${unit(hours, "hour")} ${unit(minutes, "minute")}` : unit(hours, "hour") return minutes > 0 ? unit(minutes, "minute") : "less than a minute" }) + + const message = `${limitName} usage limit reached. It will reset in ${resetIn}. To continue using this model now, enable usage from your available balance` + + const link = `https://opencode.ai/workspace/${workspace}/go` return { - message: PAYG_UPSELL_MESSAGE, + message: `${message} - ${link}`, action: { title: "Go limit reached", - message: - limit && resetIn - ? `You hit your ${limit} limit. It will reset in ${resetIn}. You can also enable pay-as-you-go.` - : "Enable pay-as-you-go to keep using Go models after your subscription quota is used.", - label: "enable PAYG", - ...(workspace ? { link: `https://opencode.ai/workspace/${workspace}/go` } : {}), + message, + label: "open settings", + link, }, } } diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 8a4d6d6af0..9bb5e48652 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -279,11 +279,13 @@ describe("session.retry.retryable", () => { ) expect(SessionRetry.retryable(error)).toEqual({ - message: SessionRetry.PAYG_UPSELL_MESSAGE, + message: + "5 hour usage limit reached. It will reset in 5 hours 23 minutes. To continue using this model now, enable usage from your available balance - https://opencode.ai/workspace/wrk_01K6XGM22R6FM8JVABE9XDQXGH/go", action: { title: "Go limit reached", - message: "You hit your 5 hour limit. It will reset in 5 hours 23 minutes. You can also enable pay-as-you-go.", - label: "enable PAYG", + message: + "5 hour usage limit reached. It will reset in 5 hours 23 minutes. To continue using this model now, enable usage from your available balance", + label: "open settings", link: "https://opencode.ai/workspace/wrk_01K6XGM22R6FM8JVABE9XDQXGH/go", }, }) From 21ae91b4f237f9ef9947a6988f4a25de3ab1c31a Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 8 May 2026 14:19:09 +0800 Subject: [PATCH 8/9] refactor(desktop): convert main process to Effect-TS (#26148) --- packages/desktop/src/main/index.ts | 650 +++++++++++---------------- packages/desktop/src/main/updater.ts | 126 ++++++ 2 files changed, 384 insertions(+), 392 deletions(-) create mode 100644 packages/desktop/src/main/updater.ts diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 52e45a702c..1b624800e8 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -7,38 +7,9 @@ import { homedir, tmpdir } from "node:os" import { join } from "node:path" import { getCACertificates, setDefaultCACertificates } from "node:tls" import type { Event } from "electron" -import { app, BrowserWindow, dialog } from "electron" -import pkg from "electron-updater" +import { app, BrowserWindow } from "electron" import contextMenu from "electron-context-menu" -contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false }) - -// on macOS apps run in `/` which can cause issues with ripgrep -try { - process.chdir(homedir()) -} catch {} - -process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true" - -const APP_NAMES: Record = { - dev: "OpenCode Dev", - beta: "OpenCode Beta", - prod: "OpenCode", -} -const APP_IDS: Record = { - dev: "ai.opencode.desktop.dev", - beta: "ai.opencode.desktop.beta", - prod: "ai.opencode.desktop", -} -const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1" -const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev" -const onboardingTestRoot = setupOnboardingTestEnv() -app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev") -app.setAppUserModelId(appId) -app.setPath("userData", onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId)) -if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session")) -const logger = initLogging() -const { autoUpdater } = pkg import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types" import { checkAppExists, resolveAppPath, wslPath } from "./apps" @@ -64,104 +35,30 @@ import { setDockIcon, } from "./windows" import { migrate } from "./migrate" +import { checkUpdate, checkForUpdates, installUpdate, setupAutoUpdater } from "./updater" +import { Deferred, Effect, Fiber } from "effect" + +const APP_NAMES: Record = { + dev: "OpenCode Dev", + beta: "OpenCode Beta", + prod: "OpenCode", +} +const APP_IDS: Record = { + dev: "ai.opencode.desktop.dev", + beta: "ai.opencode.desktop.beta", + prod: "ai.opencode.desktop", +} +const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1" + +let logger: ReturnType +let mainWindow: BrowserWindow | null = null +let server: SidecarListener | null = null const initEmitter = new EventEmitter() let initStep: InitStep = { phase: "server_waiting" } -let mainWindow: BrowserWindow | null = null -let server: SidecarListener | null = null -const loadingComplete = defer() - const pendingDeepLinks: string[] = [] -const serverReady = defer() - -useSystemCertificates() - -function setupOnboardingTestEnv() { - if (!TEST_ONBOARDING) return - - const root = join(tmpdir(), `opencode-onboarding-${randomUUID()}`) - rmSync(root, { recursive: true, force: true }) - ;["data", "config", "cache", "state", "desktop", "session"].forEach((dir) => - mkdirSync(join(root, dir), { recursive: true }), - ) - process.env.OPENCODE_DB = ":memory:" - process.env.XDG_DATA_HOME = join(root, "data") - process.env.XDG_CONFIG_HOME = join(root, "config") - process.env.XDG_CACHE_HOME = join(root, "cache") - process.env.XDG_STATE_HOME = join(root, "state") - return root -} - -logger.log("app starting", { - version: app.getVersion(), - packaged: app.isPackaged, - onboardingTest: Boolean(onboardingTestRoot), -}) - -setupApp() - -function setupApp() { - ensureLoopbackNoProxy() - useEnvProxy() - app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>") - if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222") - - if (!app.requestSingleInstanceLock()) { - app.quit() - return - } - - preferAppEnv(app.getPath("userData")) - - app.on("second-instance", (_event: Event, argv: string[]) => { - const urls = argv.filter((arg: string) => arg.startsWith("opencode://")) - if (urls.length) { - logger.log("deep link received via second-instance", { urls }) - emitDeepLinks(urls) - } - focusMainWindow() - }) - - app.on("open-url", (event: Event, url: string) => { - event.preventDefault() - logger.log("deep link received via open-url", { url }) - emitDeepLinks([url]) - }) - - app.on("before-quit", () => { - void killSidecar() - }) - - app.on("will-quit", () => { - void killSidecar() - }) - - for (const signal of ["SIGINT", "SIGTERM"] as const) { - process.on(signal, () => { - void killSidecar().finally(() => app.exit(0)) - }) - } - - void app.whenReady().then(async () => { - if (!TEST_ONBOARDING) migrate() - app.setAsDefaultProtocolClient("opencode") - registerRendererProtocol() - setDockIcon() - setupAutoUpdater() - await initialize() - }) -} - -function useSystemCertificates() { - try { - setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])]) - } catch (error) { - logger.warn("failed to load system certificates", error) - } -} - function useEnvProxy() { try { // Electron 41.2 runs Node 24.14.1; latest @types/node@24 is 24.12.2. @@ -177,145 +74,12 @@ function emitDeepLinks(urls: string[]) { if (mainWindow) sendDeepLinks(mainWindow, urls) } -function focusMainWindow() { - if (!mainWindow) return - mainWindow.show() - mainWindow.focus() -} - function setInitStep(step: InitStep) { initStep = step logger.log("init step", { step }) initEmitter.emit("step", step) } -async function initialize() { - const needsMigration = !sqliteFileExists() - let overlay: BrowserWindow | null = null - - const port = await getSidecarPort() - const hostname = "127.0.0.1" - const url = `http://${hostname}:${port}` - const password = randomUUID() - - const loadingTask = (async () => { - logger.log("sidecar connection started", { url }) - - initEmitter.on("sqlite", (progress: SqliteMigrationProgress) => { - setInitStep({ phase: "sqlite_waiting" }) - if (overlay) sendSqliteMigrationProgress(overlay, progress) - if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress) - }) - - logger.log("spawning sidecar", { url }) - const { listener, health } = await spawnLocalServer( - hostname, - port, - password, - () => { - ensureLoopbackNoProxy() - useEnvProxy() - }, - { - needsMigration, - userDataPath: app.getPath("userData"), - onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress), - onStdout: (message) => logger.log("sidecar stdout", { message }), - onStderr: (message) => logger.warn("sidecar stderr", { message }), - onExit: (code) => logger.warn("sidecar exited", { code }), - }, - ) - server = listener - serverReady.resolve({ - url, - username: "opencode", - password, - }) - - await Promise.race([ - health.wait, - delay(30_000).then(() => { - throw new Error("Sidecar health check timed out") - }), - ]).catch((error) => { - logger.error("sidecar health check failed", error) - }) - - logger.log("loading task finished") - })() - - if (needsMigration) { - const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)]) - if (show) { - overlay = createLoadingWindow() - await delay(1_000) - } - } - - await loadingTask - setInitStep({ phase: "done" }) - - if (overlay) { - await loadingComplete.promise - } - - mainWindow = createMainWindow() - wireMenu() - - overlay?.close() -} - -function wireMenu() { - if (!mainWindow) return - createMenu({ - trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id), - checkForUpdates: () => { - void checkForUpdates(true) - }, - reload: () => mainWindow?.reload(), - relaunch: () => { - void killSidecar().finally(() => { - app.relaunch() - app.exit(0) - }) - }, - }) -} - -registerIpcHandlers({ - killSidecar: () => killSidecar(), - awaitInitialization: async (sendStep) => { - sendStep(initStep) - const listener = (step: InitStep) => sendStep(step) - initEmitter.on("step", listener) - try { - logger.log("awaiting server ready") - const res = await serverReady.promise - logger.log("server ready", { url: res.url }) - return res - } finally { - initEmitter.off("step", listener) - } - }, - getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }), - consumeInitialDeepLinks: () => pendingDeepLinks.splice(0), - getDefaultServerUrl: () => getDefaultServerUrl(), - setDefaultServerUrl: (url) => setDefaultServerUrl(url), - getWslConfig: () => Promise.resolve(getWslConfig()), - setWslConfig: (config: WslConfig) => setWslConfig(config), - getDisplayBackend: async () => null, - setDisplayBackend: async () => undefined, - parseMarkdown: async (markdown) => parseMarkdown(markdown), - checkAppExists: (appName) => checkAppExists(appName), - wslPath: async (path, mode) => wslPath(path, mode), - resolveAppPath: async (appName) => resolveAppPath(appName), - loadingWindowComplete: () => loadingComplete.resolve(), - runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail), - checkUpdate: async () => checkUpdate(), - installUpdate: async () => installUpdate(), - setBackgroundColor: (color) => setBackgroundColor(color), -}) - async function killSidecar() { if (!server) return const current = server @@ -343,163 +107,265 @@ function ensureLoopbackNoProxy() { upsert("no_proxy") } -async function getSidecarPort() { - const fromEnv = process.env.OPENCODE_PORT - if (fromEnv) { - const parsed = Number.parseInt(fromEnv, 10) - if (!Number.isNaN(parsed)) return parsed +const main = Effect.gen(function* () { + contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false }) + + // on macOS apps run in `/` which can cause issues with ripgrep + try { + process.chdir(homedir()) + } catch {} + + process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true" + + const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev" + const onboardingTestRoot = ((): string | undefined => { + if (!TEST_ONBOARDING) return + + const root = join(tmpdir(), `opencode-onboarding-${randomUUID()}`) + rmSync(root, { recursive: true, force: true }) + ;["data", "config", "cache", "state", "desktop", "session"].forEach((dir) => + mkdirSync(join(root, dir), { recursive: true }), + ) + process.env.OPENCODE_DB = ":memory:" + process.env.XDG_DATA_HOME = join(root, "data") + process.env.XDG_CONFIG_HOME = join(root, "config") + process.env.XDG_CACHE_HOME = join(root, "cache") + process.env.XDG_STATE_HOME = join(root, "state") + return root + })() + app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev") + app.setAppUserModelId(appId) + app.setPath( + "userData", + onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId), + ) + if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session")) + logger = initLogging() + + try { + setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])]) + } catch (error) { + logger.warn("failed to load system certificates", error) } - return await new Promise((resolve, reject) => { + logger.log("app starting", { + version: app.getVersion(), + packaged: app.isPackaged, + onboardingTest: Boolean(onboardingTestRoot), + }) + + ensureLoopbackNoProxy() + useEnvProxy() + app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>") + if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222") + + if (!app.requestSingleInstanceLock()) { + app.quit() + return + } + + preferAppEnv(app.getPath("userData")) + + app.on("second-instance", (_event: Event, argv: string[]) => { + const urls = argv.filter((arg: string) => arg.startsWith("opencode://")) + if (urls.length) { + logger.log("deep link received via second-instance", { urls }) + emitDeepLinks(urls) + } + if (mainWindow) { + mainWindow.show() + mainWindow.focus() + } + }) + + app.on("open-url", (event: Event, url: string) => { + event.preventDefault() + logger.log("deep link received via open-url", { url }) + emitDeepLinks([url]) + }) + + app.on("before-quit", () => { + void killSidecar() + }) + + app.on("will-quit", () => { + void killSidecar() + }) + + for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, () => { + void killSidecar().finally(() => app.exit(0)) + }) + } + + const serverReady = Deferred.makeUnsafe() + const loadingComplete = Deferred.makeUnsafe() + + registerIpcHandlers({ + killSidecar: () => killSidecar(), + awaitInitialization: Effect.fnUntraced( + function* (sendStep) { + sendStep(initStep) + const listener = (step: InitStep) => sendStep(step) + initEmitter.on("step", listener) + try { + logger.log("awaiting server ready") + const res = yield* Deferred.await(serverReady) + logger.log("server ready", { url: res.url }) + return res + } finally { + initEmitter.off("step", listener) + } + }, + (e) => Effect.runPromise(e), + ), + getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }), + consumeInitialDeepLinks: () => pendingDeepLinks.splice(0), + getDefaultServerUrl: () => getDefaultServerUrl(), + setDefaultServerUrl: (url) => setDefaultServerUrl(url), + getWslConfig: () => Promise.resolve(getWslConfig()), + setWslConfig: (config: WslConfig) => setWslConfig(config), + getDisplayBackend: async () => null, + setDisplayBackend: async () => undefined, + parseMarkdown: async (markdown) => parseMarkdown(markdown), + checkAppExists: (appName) => checkAppExists(appName), + wslPath: async (path, mode) => wslPath(path, mode), + resolveAppPath: async (appName) => resolveAppPath(appName), + loadingWindowComplete: () => Deferred.doneUnsafe(loadingComplete, Effect.void), + runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail, killSidecar), + checkUpdate: async () => checkUpdate(), + installUpdate: async () => installUpdate(killSidecar), + setBackgroundColor: (color) => setBackgroundColor(color), + }) + + yield* Effect.promise(() => app.whenReady()) + + if (!TEST_ONBOARDING) migrate() + app.setAsDefaultProtocolClient("opencode") + registerRendererProtocol() + setDockIcon() + setupAutoUpdater() + + const needsMigration = ((): boolean => { + if (process.env.OPENCODE_DB === ":memory:") return false + + const xdg = process.env.XDG_DATA_HOME + const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share") + return !existsSync(join(base, "opencode", "opencode.db")) + })() + let overlay: BrowserWindow | null = null + + const port = yield* Effect.gen(function* () { + const fromEnv = process.env.OPENCODE_PORT + if (fromEnv) { + const parsed = Number.parseInt(fromEnv, 10) + if (!Number.isNaN(parsed)) return parsed + } + + const res = yield* Deferred.make() const server = createServer() - server.on("error", reject) + server.on("error", (e) => Deferred.failSync(res, () => e)) server.listen(0, "127.0.0.1", () => { const address = server.address() if (typeof address !== "object" || !address) { server.close() - reject(new Error("Failed to get port")) + Deferred.failSync(res, () => new Error("Failed to get port")) return } const port = address.port - server.close(() => resolve(port)) + server.close(() => Effect.runSync(Deferred.succeed(res, port))) }) + + return yield* Deferred.await(res) }) -} + const hostname = "127.0.0.1" + const url = `http://${hostname}:${port}` + const password = randomUUID() -function sqliteFileExists() { - if (process.env.OPENCODE_DB === ":memory:") return true + const loadingTask = yield* Effect.gen(function* () { + logger.log("sidecar connection started", { url }) - const xdg = process.env.XDG_DATA_HOME - const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share") - return existsSync(join(base, "opencode", "opencode.db")) -} - -function setupAutoUpdater() { - if (!UPDATER_ENABLED) return - autoUpdater.logger = logger - autoUpdater.channel = "latest" - autoUpdater.allowPrerelease = false - autoUpdater.allowDowngrade = true - autoUpdater.autoDownload = false - autoUpdater.autoInstallOnAppQuit = false - logger.log("auto updater configured", { - channel: autoUpdater.channel, - allowPrerelease: autoUpdater.allowPrerelease, - allowDowngrade: autoUpdater.allowDowngrade, - currentVersion: app.getVersion(), - }) -} - -let downloadedUpdateVersion: string | undefined - -async function checkUpdate() { - if (!UPDATER_ENABLED) return { updateAvailable: false } - if (downloadedUpdateVersion) { - logger.log("returning cached downloaded update", { - version: downloadedUpdateVersion, + initEmitter.on("sqlite", (progress: SqliteMigrationProgress) => { + setInitStep({ phase: "sqlite_waiting" }) + if (overlay) sendSqliteMigrationProgress(overlay, progress) + if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress) }) - return { updateAvailable: true, version: downloadedUpdateVersion } - } - logger.log("checking for updates", { - currentVersion: app.getVersion(), - channel: autoUpdater.channel, - allowPrerelease: autoUpdater.allowPrerelease, - allowDowngrade: autoUpdater.allowDowngrade, - }) - try { - const result = await autoUpdater.checkForUpdates() - const updateInfo = result?.updateInfo - logger.log("update metadata fetched", { - releaseVersion: updateInfo?.version ?? null, - releaseDate: updateInfo?.releaseDate ?? null, - releaseName: updateInfo?.releaseName ?? null, - files: updateInfo?.files?.map((file) => file.url) ?? [], + + logger.log("spawning sidecar", { url }) + const { listener, health } = yield* Effect.promise(() => + spawnLocalServer( + hostname, + port, + password, + () => { + ensureLoopbackNoProxy() + useEnvProxy() + }, + { + needsMigration, + userDataPath: app.getPath("userData"), + onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress), + onStdout: (message) => logger.log("sidecar stdout", { message }), + onStderr: (message) => logger.warn("sidecar stderr", { message }), + onExit: (code) => logger.warn("sidecar exited", { code }), + }, + ), + ) + server = listener + yield* Deferred.succeed(serverReady, { + url, + username: "opencode", + password, }) - const version = result?.updateInfo?.version - if (result?.isUpdateAvailable === false || !version) { - logger.log("no update available", { - reason: "provider returned no newer version", - }) - return { updateAvailable: false } + + yield* Effect.promise(() => health.wait).pipe( + Effect.timeout("30 seconds"), + Effect.catch((e) => + Effect.sync(() => { + logger.error("sidecar health check failed", e.toString()) + }), + ), + ) + + logger.log("loading task finished") + }).pipe(Effect.forkChild) + + if (needsMigration) { + const show = yield* loadingTask.pipe( + Fiber.await, + Effect.timeout("1 second"), + Effect.as(false), + Effect.catch(() => Effect.succeed(true)), + ) + if (show) { + overlay = createLoadingWindow() + yield* Effect.sleep("1 second") } - logger.log("update available", { version }) - await autoUpdater.downloadUpdate() - logger.log("update download completed", { version }) - downloadedUpdateVersion = version - return { updateAvailable: true, version } - } catch (error) { - logger.error("update check failed", error) - return { updateAvailable: false, failed: true } } -} -async function installUpdate() { - if (!downloadedUpdateVersion) { - logger.log("install update skipped", { - reason: "no downloaded update ready", + yield* Fiber.await(loadingTask) + setInitStep({ phase: "done" }) + + if (overlay) yield* Deferred.await(loadingComplete) + + mainWindow = createMainWindow() + if (mainWindow) { + createMenu({ + trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id), + checkForUpdates: () => { + void checkForUpdates(true, killSidecar) + }, + reload: () => mainWindow?.reload(), + relaunch: () => { + void killSidecar().finally(() => { + app.relaunch() + app.exit(0) + }) + }, }) - return - } - logger.log("installing downloaded update", { - version: downloadedUpdateVersion, - }) - await killSidecar() - autoUpdater.quitAndInstall(true, true) -} - -async function checkForUpdates(alertOnFail: boolean) { - if (!UPDATER_ENABLED) return - logger.log("checkForUpdates invoked", { alertOnFail }) - const result = await checkUpdate() - if (!result.updateAvailable) { - if (result.failed) { - logger.log("no update decision", { reason: "update check failed" }) - if (!alertOnFail) return - await dialog.showMessageBox({ - type: "error", - message: "Update check failed.", - title: "Update Error", - }) - return - } - - logger.log("no update decision", { reason: "already up to date" }) - if (!alertOnFail) return - await dialog.showMessageBox({ - type: "info", - message: "You're up to date.", - title: "No Updates", - }) - return } - const response = await dialog.showMessageBox({ - type: "info", - message: `Update ${result.version ?? ""} downloaded. Restart now?`, - title: "Update Ready", - buttons: ["Restart", "Later"], - defaultId: 0, - cancelId: 1, - }) - logger.log("update prompt response", { - version: result.version ?? null, - restartNow: response.response === 0, - }) - if (response.response === 0) { - await installUpdate() - } -} + overlay?.close() +}) -function delay(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -function defer() { - let resolve!: (value: T) => void - let reject!: (error: Error) => void - const promise = new Promise((res, rej) => { - resolve = res - reject = rej - }) - return { promise, resolve, reject } -} +Effect.runFork(main) diff --git a/packages/desktop/src/main/updater.ts b/packages/desktop/src/main/updater.ts new file mode 100644 index 0000000000..a220e00891 --- /dev/null +++ b/packages/desktop/src/main/updater.ts @@ -0,0 +1,126 @@ +import { app, dialog } from "electron" +import pkg from "electron-updater" +import { UPDATER_ENABLED } from "./constants" +import { initLogging } from "./logging" + +const logger = initLogging() +const { autoUpdater } = pkg + +let downloadedUpdateVersion: string | undefined + +export function setupAutoUpdater() { + if (!UPDATER_ENABLED) return + autoUpdater.logger = logger + autoUpdater.channel = "latest" + autoUpdater.allowPrerelease = false + autoUpdater.allowDowngrade = true + autoUpdater.autoDownload = false + autoUpdater.autoInstallOnAppQuit = false + logger.log("auto updater configured", { + channel: autoUpdater.channel, + allowPrerelease: autoUpdater.allowPrerelease, + allowDowngrade: autoUpdater.allowDowngrade, + currentVersion: app.getVersion(), + }) +} + +export async function checkUpdate() { + if (!UPDATER_ENABLED) return { updateAvailable: false } + if (downloadedUpdateVersion) { + logger.log("returning cached downloaded update", { + version: downloadedUpdateVersion, + }) + return { updateAvailable: true, version: downloadedUpdateVersion } + } + logger.log("checking for updates", { + currentVersion: app.getVersion(), + channel: autoUpdater.channel, + allowPrerelease: autoUpdater.allowPrerelease, + allowDowngrade: autoUpdater.allowDowngrade, + }) + try { + const result = await autoUpdater.checkForUpdates() + const updateInfo = result?.updateInfo + logger.log("update metadata fetched", { + releaseVersion: updateInfo?.version ?? null, + releaseDate: updateInfo?.releaseDate ?? null, + releaseName: updateInfo?.releaseName ?? null, + files: updateInfo?.files?.map((file) => file.url) ?? [], + }) + const version = result?.updateInfo?.version + if (result?.isUpdateAvailable === false || !version) { + logger.log("no update available", { + reason: "provider returned no newer version", + }) + return { updateAvailable: false } + } + logger.log("update available", { version }) + await autoUpdater.downloadUpdate() + logger.log("update download completed", { version }) + downloadedUpdateVersion = version + return { updateAvailable: true, version } + } catch (error) { + logger.error("update check failed", error) + return { updateAvailable: false, failed: true } + } +} + +export async function installUpdate(killSidecar: () => Promise) { + if (!downloadedUpdateVersion) { + logger.log("install update skipped", { + reason: "no downloaded update ready", + }) + return + } + logger.log("installing downloaded update", { + version: downloadedUpdateVersion, + }) + await killSidecar() + autoUpdater.quitAndInstall() +} + +export async function checkForUpdates( + alertOnFail: boolean, + killSidecar: () => Promise, +) { + if (!UPDATER_ENABLED) return + logger.log("checkForUpdates invoked", { alertOnFail }) + const result = await checkUpdate() + if (!result.updateAvailable) { + if (result.failed) { + logger.log("no update decision", { reason: "update check failed" }) + if (!alertOnFail) return + await dialog.showMessageBox({ + type: "error", + message: "Update check failed.", + title: "Update Error", + }) + return + } + + logger.log("no update decision", { reason: "already up to date" }) + if (!alertOnFail) return + await dialog.showMessageBox({ + type: "info", + message: "You're up to date.", + title: "No Updates", + }) + return + } + + const response = await dialog.showMessageBox({ + type: "info", + message: `Update ${result.version ?? ""} downloaded. Restart now?`, + title: "Update Ready", + buttons: ["Restart", "Later"], + defaultId: 0, + cancelId: 1, + }) + logger.log("update prompt response", { + version: result.version ?? null, + restartNow: response.response === 0, + }) + if (response.response === 0) { + await installUpdate(killSidecar) + } +} From 014dbd34c4f5612d9a037b3641a8244b213a8a30 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 8 May 2026 06:20:21 +0000 Subject: [PATCH 9/9] chore: generate --- packages/desktop/src/main/updater.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/desktop/src/main/updater.ts b/packages/desktop/src/main/updater.ts index a220e00891..b7f4bce785 100644 --- a/packages/desktop/src/main/updater.ts +++ b/packages/desktop/src/main/updater.ts @@ -79,10 +79,7 @@ export async function installUpdate(killSidecar: () => Promise) { autoUpdater.quitAndInstall() } -export async function checkForUpdates( - alertOnFail: boolean, - killSidecar: () => Promise, -) { +export async function checkForUpdates(alertOnFail: boolean, killSidecar: () => Promise) { if (!UPDATER_ENABLED) return logger.log("checkForUpdates invoked", { alertOnFail }) const result = await checkUpdate()