From 3aa8d3c133ffdbf23e61b83c4e22435f17e147fb Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:43:04 +0200 Subject: [PATCH 1/3] fix(opencode): defer desktop recovery notices --- .github/workflows/ci.yml | 2 +- packages/opencode/src/index.ts | 133 ++++++++++++++-------- packages/opencode/src/tests/index.test.ts | 96 +++++++++++----- 3 files changed, 150 insertions(+), 81 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df6dedcc..ef654c9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: env: TUI_SMOKE_SKIP_BUILD: "1" - run: bun run test - - run: npm install -g opencode-ai@1.17.13 + - run: npm install -g opencode-ai@1.18.18 - run: bun run test:e2e - run: bun run format:check - run: bun run lint diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index abced905..1b06f9df 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -390,6 +390,8 @@ type PluginSessionClient = { status?: () => Promise | unknown } +const DESKTOP_NOTICE_PROBE_LIMIT = 4 + type PerfTrace = { requestId: string start: number @@ -539,12 +541,7 @@ async function sendIgnoredMessage( promptContext.latestUserMessageId, ) : undefined - if (!messageID) { - throw new Error( - 'OpenCode assistant ordering is unavailable for the fallback notification.', - ) - } - request.body.messageID = messageID + if (messageID) request.body.messageID = messageID } if (promptContext?.agent) request.body.agent = promptContext.agent if (promptContext?.model) request.body.model = promptContext.model @@ -865,6 +862,9 @@ const anthropicAuthPlugin = async ( const serverFallbackTargets = new Map() const pendingDesktopNotices = new Map() const desktopNoticeFlushes = new Map>() + const desktopNoticePostIdleUpdates = new Set() + const desktopNoticeSafeSessions = new Set() + const desktopNoticeProbes = new Map() const stickySessionRouter = new StickySessionRouter({ path: process.env.OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE || @@ -1781,11 +1781,8 @@ const anthropicAuthPlugin = async ( if (!desktopText || isTuiConnected(notice.sessionId)) return // OpenCode's prompt endpoints run revert cleanup before honoring noReply. - // Creating a notification while an assistant is still streaming can race the - // active run and enqueue an extra provider turn. Queue it until OpenCode - // publishes the assistant's completed message update or becomes idle, then - // place it directly before that assistant in ID order. The status probe below - // closes the race where both events precede a delayed cache warm/outcome. + // OpenCode awaits event handlers before it evaluates the loop exit condition. + // Escape the post-idle session update, then probe outside that critical section. const queue = pendingDesktopNotices.get(notice.sessionId) ?? [] queue.push(desktopText) if (queue.length > 4) queue.splice(0, queue.length - 4) @@ -1796,40 +1793,64 @@ const anthropicAuthPlugin = async ( if (oldest) pendingDesktopNotices.delete(oldest) else break } - void flushDesktopNoticesIfIdle(notice.sessionId) + if (desktopNoticeSafeSessions.has(notice.sessionId)) { + scheduleDesktopNoticeProbe(notice.sessionId) + } } - async function flushDesktopNoticesIfIdle(sessionId: string): Promise { - const session = ctx.client.session as PluginSessionClient | undefined - if (typeof session?.status !== 'function') return + function scheduleDesktopNoticeProbe(sessionId: string, attempt = 0) { + if ( + !pendingDesktopNotices.has(sessionId) || + desktopNoticeProbes.has(sessionId) + ) { + return + } + desktopNoticeProbes.set(sessionId, attempt) + setImmediate(() => { + if (desktopNoticeProbes.get(sessionId) !== attempt) return + desktopNoticeProbes.delete(sessionId) + void flushDesktopNoticesIfIdle(sessionId, attempt) + }) + } - try { - const response = await Promise.resolve(session.status()) - const responseRecord = - response !== null && typeof response === 'object' - ? (response as Record) - : undefined - const data = - responseRecord && Object.hasOwn(responseRecord, 'data') - ? responseRecord.data - : responseRecord - if (data === null || typeof data !== 'object' || Array.isArray(data)) - return - const status = (data as Record)[sessionId] - if (status === undefined) { - await flushDesktopNotices(sessionId) + async function flushDesktopNoticesIfIdle(sessionId: string, attempt: number) { + if ( + !desktopNoticeSafeSessions.has(sessionId) || + !pendingDesktopNotices.has(sessionId) + ) { + return + } + const session = ctx.client.session as PluginSessionClient | undefined + if (typeof session?.status === 'function') { + try { + const response = await Promise.resolve(session.status()) + const responseRecord = + response !== null && typeof response === 'object' + ? (response as Record) + : undefined + const data = + responseRecord && Object.hasOwn(responseRecord, 'data') + ? responseRecord.data + : responseRecord + if (data === null || typeof data !== 'object' || Array.isArray(data)) + return + const status = (data as Record)[sessionId] + if ( + status !== undefined && + (!status || + typeof status !== 'object' || + (status as { type?: unknown }).type !== 'idle') + ) { + if (attempt < DESKTOP_NOTICE_PROBE_LIMIT) { + scheduleDesktopNoticeProbe(sessionId, attempt + 1) + } + return + } + } catch { return } - if ( - status !== null && - typeof status === 'object' && - (status as { type?: unknown }).type === 'idle' - ) { - await flushDesktopNotices(sessionId) - } - } catch { - // Event-driven flushing remains the compatibility path for older hosts. } + await flushDesktopNotices(sessionId) } function flushDesktopNotices(sessionId: string): Promise { @@ -2570,9 +2591,6 @@ const anthropicAuthPlugin = async ( info?: { id?: string sessionID?: string - role?: string - finish?: string - time?: { completed?: number } } status?: { type?: string } } @@ -2584,23 +2602,40 @@ const anthropicAuthPlugin = async ( if ( value.type === 'session.status' && - value.properties?.status?.type === 'idle' + value.properties?.status?.type !== 'idle' ) { - await flushDesktopNotices(sessionId) + desktopNoticePostIdleUpdates.delete(sessionId) + desktopNoticeSafeSessions.delete(sessionId) + } + + if (value.type === 'session.idle') { + desktopNoticePostIdleUpdates.add(sessionId) + while (desktopNoticePostIdleUpdates.size > 128) { + const oldest = desktopNoticePostIdleUpdates.values().next().value + if (oldest) desktopNoticePostIdleUpdates.delete(oldest) + else break + } } if ( - value.type === 'message.updated' && - info?.role === 'assistant' && - info.finish !== 'tool-calls' && - typeof info.time?.completed === 'number' + value.type === 'session.updated' && + desktopNoticePostIdleUpdates.delete(sessionId) ) { - await flushDesktopNotices(sessionId) + desktopNoticeSafeSessions.add(sessionId) + while (desktopNoticeSafeSessions.size > 128) { + const oldest = desktopNoticeSafeSessions.values().next().value + if (oldest) desktopNoticeSafeSessions.delete(oldest) + else break + } + scheduleDesktopNoticeProbe(sessionId) } if (value.type === 'session.deleted') { fableRecoveryNotices.delete(sessionId) pendingDesktopNotices.delete(sessionId) + desktopNoticePostIdleUpdates.delete(sessionId) + desktopNoticeSafeSessions.delete(sessionId) + desktopNoticeProbes.delete(sessionId) } }, config: async (config: { command?: Record }) => { diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 89e1c61e..70599e0c 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -7694,6 +7694,33 @@ describe('auth.loader', () => { }, }, }) + expect(mockClient.session.promptAsync).not.toHaveBeenCalled() + + await plugin.event?.({ + event: { + type: 'session.status', + properties: { + sessionID: 'ses_server_fallback', + status: { type: 'idle' }, + }, + }, + }) + expect(mockClient.session.promptAsync).not.toHaveBeenCalled() + + await plugin.event?.({ + event: { + type: 'session.idle', + properties: { sessionID: 'ses_server_fallback' }, + }, + }) + expect(mockClient.session.promptAsync).not.toHaveBeenCalled() + + await plugin.event?.({ + event: { + type: 'session.updated', + properties: { sessionID: 'ses_server_fallback' }, + }, + }) await waitForMockCall(mockClient.session.promptAsync) expect(mockClient.session.promptAsync.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ @@ -7711,8 +7738,8 @@ describe('auth.loader', () => { const restoredResponse = await result.fetch(MESSAGES_URL, request) // OpenCode can publish the assistant-completed event before the wrapped - // response emits its final fallback outcome. The later idle event must flush - // a notice queued after that completion event without starting another turn. + // response emits its final fallback outcome. The post-idle session update must + // flush a notice queued after that completion event without starting another turn. await plugin.event?.({ event: { type: 'message.updated', @@ -7741,11 +7768,14 @@ describe('auth.loader', () => { ) await plugin.event?.({ event: { - type: 'session.status', - properties: { - sessionID: 'ses_server_fallback', - status: { type: 'idle' }, - }, + type: 'session.idle', + properties: { sessionID: 'ses_server_fallback' }, + }, + }) + await plugin.event?.({ + event: { + type: 'session.updated', + properties: { sessionID: 'ses_server_fallback' }, }, }) await waitForMockCall({ @@ -7830,7 +7860,7 @@ describe('auth.loader', () => { const latestUserMessageId = 'msg_000000000100AAAAAAAAAAAAAA' const latestAssistantMessageId = 'msg_000000000200BBBBBBBBBBBBBB' - let sessionIdle = false + let noticeStatusChecks = 0 const mockClient = createMockClient( [ { @@ -7857,7 +7887,9 @@ describe('auth.loader', () => { }, ], (): Record => - sessionIdle ? {} : { ses_fable_filter: { type: 'busy' } }, + noticeStatusChecks++ === 0 + ? { ses_fable_filter: { type: 'busy' } } + : {}, ) const plugin = await getPlugin(mockClient) const result = await plugin.auth.loader( @@ -7911,17 +7943,17 @@ describe('auth.loader', () => { await firstOpus.text() await plugin.event?.({ event: { - type: 'message.updated', - properties: { - info: { - id: latestAssistantMessageId, - sessionID: 'ses_fable_filter', - role: 'assistant', - time: { completed: Date.now() }, - }, - }, + type: 'session.idle', + properties: { sessionID: 'ses_fable_filter' }, }, }) + await plugin.event?.({ + event: { + type: 'session.updated', + properties: { sessionID: 'ses_fable_filter' }, + }, + }) + await waitForMockCall(mockClient.session.promptAsync) expect(mockClient.session.promptAsync).toHaveBeenCalledTimes(1) expect(mockClient.session.promptAsync.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ @@ -8032,14 +8064,16 @@ describe('auth.loader', () => { // Reproduce the host race: OpenCode can publish idle while the final cache // warm is still pending, before the restoration notice has been queued. - sessionIdle = true await plugin.event?.({ event: { - type: 'session.status', - properties: { - sessionID: 'ses_fable_filter', - status: { type: 'idle' }, - }, + type: 'session.idle', + properties: { sessionID: 'ses_fable_filter' }, + }, + }) + await plugin.event?.({ + event: { + type: 'session.updated', + properties: { sessionID: 'ses_fable_filter' }, }, }) expect(mockClient.session.promptAsync).toHaveBeenCalledTimes(1) @@ -8049,13 +8083,13 @@ describe('auth.loader', () => { await restored.text() expect(normalModels.at(-1)).toBe('claude-fable-5') - for ( - let attempt = 0; - attempt < 100 && mockClient.session.promptAsync.mock.calls.length < 2; - attempt++ - ) { - await new Promise((resolve) => setTimeout(resolve, 1)) - } + await waitForMockCall({ + mock: { + get calls() { + return mockClient.session.promptAsync.mock.calls.slice(1) + }, + }, + }) expect(mockClient.session.promptAsync).toHaveBeenCalledTimes(2) expect(mockClient.session.promptAsync.mock.calls[1]?.[0]).toEqual( expect.objectContaining({ From 2d9cac19a6f93dff52cb8cdfd9caca24535d2254 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:15:57 +0200 Subject: [PATCH 2/3] fix(opencode): retry inconclusive notice probes --- packages/opencode/src/index.ts | 26 +++++++++++++++++------ packages/opencode/src/tests/index.test.ts | 16 ++++++++++---- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 1b06f9df..fa37b2dc 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -391,6 +391,7 @@ type PluginSessionClient = { } const DESKTOP_NOTICE_PROBE_LIMIT = 4 +const DESKTOP_NOTICE_PROBE_DELAY_MS = 25 type PerfTrace = { requestId: string @@ -1806,11 +1807,22 @@ const anthropicAuthPlugin = async ( return } desktopNoticeProbes.set(sessionId, attempt) - setImmediate(() => { + const run = () => { if (desktopNoticeProbes.get(sessionId) !== attempt) return desktopNoticeProbes.delete(sessionId) void flushDesktopNoticesIfIdle(sessionId, attempt) - }) + } + if (attempt === 0) { + setImmediate(run) + } else { + setTimeout(run, DESKTOP_NOTICE_PROBE_DELAY_MS * attempt) + } + } + + function rearmDesktopNoticeProbe(sessionId: string, attempt: number) { + if (attempt + 1 < DESKTOP_NOTICE_PROBE_LIMIT) { + scheduleDesktopNoticeProbe(sessionId, attempt + 1) + } } async function flushDesktopNoticesIfIdle(sessionId: string, attempt: number) { @@ -1832,21 +1844,23 @@ const anthropicAuthPlugin = async ( responseRecord && Object.hasOwn(responseRecord, 'data') ? responseRecord.data : responseRecord - if (data === null || typeof data !== 'object' || Array.isArray(data)) + if (data === null || typeof data !== 'object' || Array.isArray(data)) { + rearmDesktopNoticeProbe(sessionId, attempt) return + } const status = (data as Record)[sessionId] + // OpenCode 1.17 and 1.18 omit idle sessions from this map. if ( status !== undefined && (!status || typeof status !== 'object' || (status as { type?: unknown }).type !== 'idle') ) { - if (attempt < DESKTOP_NOTICE_PROBE_LIMIT) { - scheduleDesktopNoticeProbe(sessionId, attempt + 1) - } + rearmDesktopNoticeProbe(sessionId, attempt) return } } catch { + rearmDesktopNoticeProbe(sessionId, attempt) return } } diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 70599e0c..0cbd45ad 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -7886,10 +7886,18 @@ describe('auth.loader', () => { }, }, ], - (): Record => - noticeStatusChecks++ === 0 - ? { ses_fable_filter: { type: 'busy' } } - : {}, + (): Record => { + if (noticeStatusChecks++ === 0) { + throw new Error('transient status failure') + } + if (noticeStatusChecks === 2) { + return [] as unknown as Record + } + if (noticeStatusChecks === 3) { + return { ses_fable_filter: { type: 'busy' } } + } + return {} + }, ) const plugin = await getPlugin(mockClient) const result = await plugin.auth.loader( From 0ee8cd18c0299ef4e927b350d01566d4422d45ff Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:32:11 +0200 Subject: [PATCH 3/3] docs: describe deferred desktop notice delivery --- ARCHITECTURE.md | 2 +- CHANGELOG.md | 2 ++ packages/opencode/CHANGELOG.md | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 24df9d0d..039544f8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -51,7 +51,7 @@ 6. **Request body rewrite** — `rewriteRequestBody()` strips trailing assistant messages and trailing whitespace after tool_use, normalizes Fable/Mythos, Sonnet 5, and Opus 5 adaptive thinking, injects billing header, sanitizes system prompt (removes OpenCode identity), prepends Claude Code identity, applies cache strategy (explicit/automatic/hybrid), adds fast mode, prefixes tool names with `mcp_`, opts eligible OAuth Fable 5/Opus 5 requests into Anthropic server-side safety fallback, restores stored fallback boundary markers, and creates `cch` over the serialized body — `packages/opencode/src/transform.ts`, `packages/opencode/src/server-fallback.ts` 7. **Routing** — `shouldFallbackStatus()` checks if response should trigger fallback; `FallbackAccountManager` iterates accounts in ordered modes, while `StickySessionRouter` assigns cold sessions by reset-normalized spendable OAuth quota and weighted initial-prompt deficit, then persists hashed session affinity across processes/restarts. Sticky routes retain transient failures, hold confirmed 5h exhaustion when reset is within 15 minutes, and migrate for longer confirmed exhaustion/permanent account failure. All modes respect model-scoped quotas and killswitch thresholds (including per-model scoped thresholds). If all accounts fail the killswitch policy, a 429 block response is returned immediately; this block is classified as scoped-driven (matching a specific model's weekly limit) or account-level (5h/7d limits) with a model-specific or generic retry hint — `packages/core/src/routing.ts`, `packages/core/src/accounts.ts`, `packages/opencode/src/index.ts` 8. **Relay** — `sendViaRelay()` sends full or patched body to Cloudflare Worker, which streams Anthropic response back. HTTP relay responses and WebSocket `response_start` control messages deliver genuine upstream headers to the OpenCode account-bound quota harvester; synthetic optimistic response headers are never harvest input, and relay-to-direct fallback stays owned by the direct path — `packages/core/src/relay.ts`, `packages/opencode/src/index.ts` -9. **SSE stream and content-filter safety fallback** — By default, OAuth Fable 5 and Opus 5 requests send `fallbacks: "default"` with Anthropic's `server-side-fallback-2026-07-01` beta. The stream wrapper detects initial `fallback` blocks, sticky `fallback_message` usage iterations, and restoration to the requested model. Because OpenCode does not natively preserve `fallback` content blocks, the wrapper rewrites each block into a hidden signed thinking marker for storage, then the next eligible request restores the exact Anthropic fallback boundary before signing and sending. If an eligible OAuth response still terminates in refusal, the deterministic 10-successful-response Opus 4.8 recovery activates as a client-side backstop. Source-model prewarms explicitly remove `fallbacks` so they cannot be sticky-routed back to a fallback model. If a served fallback completes a `tool_use` before its terminal refusal, the wrapper preserves that completed call, changes the terminal finish to `tool_use`, and lets OpenCode continue on Opus 4.8 with the existing `tool_result` instead of replaying the tool. The active Anthropic-selected target and later restoration are written per session to the TUI sidebar state; when no matching TUI is connected, OpenCode Desktop receives an ignored/no-reply `promptAsync` notice after the terminal assistant completes, ordered immediately before that assistant message. Intermediate `tool-calls` completions do not flush notices. If a delayed cache warm or fallback outcome lands after both completion and idle events, the plugin checks OpenCode's live session-status map and flushes the queued notice only when that session is idle. Custom API-key routes strip the beta and `fallbacks` field. Setting `OPENCODE_ANTHROPIC_AUTH_FALLBACK_MODE=legacy` bypasses the server policy and uses the same deterministic Opus 4.8 recovery from the first refusal, keyed by session and source-model family with account-bound source-model prewarming and standby cache bridges beyond Anthropic's 20-block lookback — `packages/opencode/src/transform.ts`, `packages/opencode/src/server-fallback.ts`, `packages/opencode/src/fable-fallback.ts`, `packages/opencode/src/prompt-context.ts`, `packages/opencode/src/index.ts` +9. **SSE stream and content-filter safety fallback** — By default, OAuth Fable 5 and Opus 5 requests send `fallbacks: "default"` with Anthropic's `server-side-fallback-2026-07-01` beta. The stream wrapper detects initial `fallback` blocks, sticky `fallback_message` usage iterations, and restoration to the requested model. Because OpenCode does not natively preserve `fallback` content blocks, the wrapper rewrites each block into a hidden signed thinking marker for storage, then the next eligible request restores the exact Anthropic fallback boundary before signing and sending. If an eligible OAuth response still terminates in refusal, the deterministic 10-successful-response Opus 4.8 recovery activates as a client-side backstop. Source-model prewarms explicitly remove `fallbacks` so they cannot be sticky-routed back to a fallback model. If a served fallback completes a `tool_use` before its terminal refusal, the wrapper preserves that completed call, changes the terminal finish to `tool_use`, and lets OpenCode continue on Opus 4.8 with the existing `tool_result` instead of replaying the tool. The active Anthropic-selected target and later restoration are written per session to the TUI sidebar state; when no matching TUI is connected, OpenCode Desktop receives an ignored/no-reply `promptAsync` notice that the plugin queues and arms for delivery on the `session.updated` event following `session.idle`, then escapes the awaited event handler with `setImmediate` because OpenCode evaluates the run-loop exit condition only after awaiting event handlers. Intermediate `tool-calls` completions do not flush notices. The plugin probes `session.status()` outside that critical section, re-arming inconclusive probes for at most four attempts, with attempt 0 on `setImmediate` and later attempts delayed by `25` ms multiplied by the attempt number, and attempts best-effort message-ID placement before the active assistant; the previous ordering guarantee was abandoned because on OpenCode 1.18 and newer a notice that becomes the latest user message makes the run loop invoke the provider again on the same turn, producing a duplicate billed provider turn. Custom API-key routes strip the beta and `fallbacks` field. Setting `OPENCODE_ANTHROPIC_AUTH_FALLBACK_MODE=legacy` bypasses the server policy and uses the same deterministic Opus 4.8 recovery from the first refusal, keyed by session and source-model family with account-bound source-model prewarming and standby cache bridges beyond Anthropic's 20-block lookback — `packages/opencode/src/transform.ts`, `packages/opencode/src/server-fallback.ts`, `packages/opencode/src/fable-fallback.ts`, `packages/opencode/src/prompt-context.ts`, `packages/opencode/src/index.ts` 10. **Sidebar update** — `writeSidebarState()` writes quota/routing/cache state plus bounded per-session Fable recovery status to a JSON file read by the TUI sidebar widget (separate process via RPC). Routing-authoritative writes (e.g. active routing decisions) are distinguished from display-only/metadata writes (e.g. quota refreshes or command paths). Display-only writes re-read the file and merge state to preserve any live routing session's `activeId` and route. Cross-process writes are synchronized using an atomic `mkdir` directory lock with jittered retries, rename-claim eviction (tolerating `ENOENT` and `EINVAL` race conditions during marker eviction), and lock-budget exhaustion skips. The write is fenced: ownership is verified before and after the rename, triggering one bounded locked repair of routing-authoritative fields on post-rename loss. — `packages/opencode/src/sidebar-state.ts`, `packages/opencode/src/index.ts` **Pi Request Lifecycle:** diff --git a/CHANGELOG.md b/CHANGELOG.md index 63c54772..26149660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ This repo is a CortexKit-maintained Anthropic auth monorepo for OpenCode and Pi. ## Unreleased +- Deliver OpenCode Desktop recovery notices without triggering an extra billed provider turn on OpenCode 1.18 and newer. + ## 1.19.1 ### Patch Changes diff --git a/packages/opencode/CHANGELOG.md b/packages/opencode/CHANGELOG.md index 48a27ff3..ee55d693 100644 --- a/packages/opencode/CHANGELOG.md +++ b/packages/opencode/CHANGELOG.md @@ -4,6 +4,8 @@ This package is a CortexKit-maintained fork of the original `@ex-machina/opencod ## Unreleased +- Deliver OpenCode Desktop recovery notices without triggering an extra billed provider turn on OpenCode 1.18 and newer. + ## 1.19.1 ### Patch Changes