Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
147 changes: 98 additions & 49 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,9 @@ type PluginSessionClient = {
status?: () => Promise<unknown> | unknown
}

const DESKTOP_NOTICE_PROBE_LIMIT = 4
const DESKTOP_NOTICE_PROBE_DELAY_MS = 25

type PerfTrace = {
requestId: string
start: number
Expand Down Expand Up @@ -539,12 +542,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
Expand Down Expand Up @@ -865,6 +863,9 @@ const anthropicAuthPlugin = async (
const serverFallbackTargets = new Map<string, string>()
const pendingDesktopNotices = new Map<string, string[]>()
const desktopNoticeFlushes = new Map<string, Promise<void>>()
const desktopNoticePostIdleUpdates = new Set<string>()
const desktopNoticeSafeSessions = new Set<string>()
const desktopNoticeProbes = new Map<string, number>()
const stickySessionRouter = new StickySessionRouter({
path:
process.env.OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE ||
Expand Down Expand Up @@ -1781,11 +1782,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)
Expand All @@ -1796,40 +1794,77 @@ 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<void> {
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)
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)
}
}

try {
const response = await Promise.resolve(session.status())
const responseRecord =
response !== null && typeof response === 'object'
? (response as Record<string, unknown>)
: 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<string, unknown>)[sessionId]
if (status === undefined) {
await flushDesktopNotices(sessionId)
function rearmDesktopNoticeProbe(sessionId: string, attempt: number) {
if (attempt + 1 < DESKTOP_NOTICE_PROBE_LIMIT) {
scheduleDesktopNoticeProbe(sessionId, attempt + 1)
}
}

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<string, unknown>)
: undefined
const data =
responseRecord && Object.hasOwn(responseRecord, 'data')
? responseRecord.data
: responseRecord
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
rearmDesktopNoticeProbe(sessionId, attempt)
return
}
const status = (data as Record<string, unknown>)[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')
) {
rearmDesktopNoticeProbe(sessionId, attempt)
return
}
} catch {
rearmDesktopNoticeProbe(sessionId, attempt)
return
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}
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<void> {
Expand Down Expand Up @@ -2570,9 +2605,6 @@ const anthropicAuthPlugin = async (
info?: {
id?: string
sessionID?: string
role?: string
finish?: string
time?: { completed?: number }
}
status?: { type?: string }
}
Expand All @@ -2584,23 +2616,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<string, unknown> }) => {
Expand Down
Loading