Skip to content
Merged
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
3 changes: 2 additions & 1 deletion packages/opencode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,10 @@
"LICENSE"
],
"scripts": {
"build": "rm -rf dist && bun build src/index.ts src/cli.ts src/sidebar-state.ts src/tui-preferences.ts src/rpc/rpc-client.ts src/rpc/port-file.ts src/rpc/protocol.ts src/rpc/rpc-dir.ts --outdir dist --target node --format esm --splitting --external @opencode-ai/plugin --minify && tsc -p tsconfig.build.json --emitDeclarationOnly && bun run build:tui",
"build": "rm -rf dist && bun build src/index.ts src/cli.ts src/sidebar-state.ts src/tui-preferences.ts src/rpc/rpc-client.ts src/rpc/port-file.ts src/rpc/protocol.ts src/rpc/rpc-dir.ts --outdir dist --target node --format esm --splitting --external @opencode-ai/plugin --minify && tsc -p tsconfig.build.json --emitDeclarationOnly && bun run build:tui && bun run check:bundle",
"build:tui": "bun scripts/build-tui.ts",
"smoke:tui": "bun scripts/smoke-tui-pack-install.ts",
"check:bundle": "bun scripts/check-bundle-globals.ts",
"build:dev": "rm -rf dist && tsc -p tsconfig.build.json",
"dev": "bun ../../scripts/dev.ts",
"dev:clean": "bun ../../scripts/dev-clean.ts",
Expand Down
35 changes: 35 additions & 0 deletions packages/opencode/scripts/check-bundle-globals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { readFile, stat } from 'node:fs/promises'
import { join } from 'node:path'

const bundlePath = join(import.meta.dir, '..', 'dist', 'index.js')
const minBundleBytes = 1024

let size: number
try {
size = (await stat(bundlePath)).size
} catch {
throw new Error(`Bundle artifact check failed: ${bundlePath} is missing`)
}

if (size <= minBundleBytes) {
throw new Error(
`Bundle artifact check failed: ${bundlePath} is not substantial (${size} bytes)`,
)
}

const bundle = await readFile(bundlePath, 'utf8')
const registryMatches = bundle.match(/__anthropicAuthRpcServers/g)?.length ?? 0
if (registryMatches === 0) {
throw new Error(
'Bundle positive-control check failed: __anthropicAuthRpcServers is absent',
)
}

// This catches one identifier; the positive control makes its zero assertion meaningful, not proof that no other stale global exists.
const singularMatches =
bundle.match(/__anthropicAuthRpcServer(?!s)/g)?.length ?? 0
if (singularMatches !== 0) {
throw new Error(
`Bundle stale-global check failed: __anthropicAuthRpcServer appears ${singularMatches} time(s)`,
)
}
85 changes: 73 additions & 12 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ import {
stickyRouteFamilyForModel,
tokenFingerprint,
} from '@cortexkit/anthropic-auth-core'
import type { Plugin } from '@opencode-ai/plugin'
import type { Hooks, Plugin } from '@opencode-ai/plugin'

import {
applyCacheDiagnosticsOptIn,
Expand Down Expand Up @@ -214,7 +214,10 @@ import {
LANE_START_REQUEST_HEADER,
LaneStartTracker,
} from './lane-start.ts'
import { adoptPrimeManager } from './prime-manager-registry.ts'
import {
adoptPrimeManager,
releasePrimeManager,
} from './prime-manager-registry.ts'
import { resolvePromptContext } from './prompt-context.ts'
import {
formatKillswitchBlockMessage,
Expand Down Expand Up @@ -2806,27 +2809,88 @@ const anthropicAuthPlugin = async (
}

let rpcServer: RpcServerHandle | null = null
let rpcDir: string | null = null
if (ctx.directory) {
const rpcGlobal = globalThis as {
__anthropicAuthRpcServer?: RpcServerHandle
__anthropicAuthRpcServers?: Map<string, RpcServerHandle>
}
if (rpcGlobal.__anthropicAuthRpcServer) {
await rpcGlobal.__anthropicAuthRpcServer.stop().catch(() => {})
rpcGlobal.__anthropicAuthRpcServer = undefined
rpcDir = getRpcDir(ctx.directory)
const rpcServers =
rpcGlobal.__anthropicAuthRpcServers ?? new Map<string, RpcServerHandle>()
rpcGlobal.__anthropicAuthRpcServers = rpcServers
const previousRpcServer = rpcServers.get(rpcDir)
if (previousRpcServer) {
await previousRpcServer.stop().catch(() => {})
rpcServers.delete(rpcDir)
}
try {
rpcServer = await startRpcServer({
dir: getRpcDir(ctx.directory),
dir: rpcDir,
drain: drainNotifications,
apply: applyCommand,
})
rpcGlobal.__anthropicAuthRpcServer = rpcServer
rpcServers.set(rpcDir, rpcServer)
} catch (error) {
logger.warn('rpc', 'failed to start', {
error: error instanceof Error ? error.message : String(error),
})
}
}
const dispose: NonNullable<Hooks['dispose']> = async () => {
try {
await quotaHeaderFeedRegistry?.dispose()
} catch (error) {
logger.warn('quota-header-feed', 'failed to dispose', {
error: error instanceof Error ? error.message : String(error),
})
}
try {
claustrumCredentialCache?.close()
} catch (error) {
logger.warn('claustrum', 'failed to close credential cache', {
error: error instanceof Error ? error.message : String(error),
})
}
// Per-instance background services must be torn down before the RPC
// guard so a disposed instance never leaves its timer running for the
// rest of the process. Each step is isolated: one failure cannot skip
// the others.
try {
fallbackManager.stopBackgroundRefresh()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: After the auth loader starts mainBackgroundRefreshTimer, disposing this plugin never clears it. Clear mainBackgroundRefreshTimer with runtimeTimers.clearInterval and set it to null during disposal so reloads do not leave stale auth refresh workers running.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/index.ts, line 2859:

<comment>After the auth loader starts `mainBackgroundRefreshTimer`, disposing this plugin never clears it. Clear `mainBackgroundRefreshTimer` with `runtimeTimers.clearInterval` and set it to `null` during disposal so reloads do not leave stale auth refresh workers running.</comment>

<file context>
@@ -2806,27 +2809,88 @@ const anthropicAuthPlugin = async (
+    // rest of the process. Each step is isolated: one failure cannot skip
+    // the others.
+    try {
+      fallbackManager.stopBackgroundRefresh()
+    } catch (error) {
+      logger.warn('fallback-background', 'failed to stop', {
</file context>
Suggested change
fallbackManager.stopBackgroundRefresh()
fallbackManager.stopBackgroundRefresh()
if (mainBackgroundRefreshTimer) {
runtimeTimers.clearInterval(mainBackgroundRefreshTimer)
mainBackgroundRefreshTimer = null
}

} catch (error) {
logger.warn('fallback-background', 'failed to stop', {
error: error instanceof Error ? error.message : String(error),
})
}
try {
cacheKeepManager.stop()
} catch (error) {
logger.warn('cachekeep', 'failed to stop', {
error: error instanceof Error ? error.message : String(error),
})
}
try {
releasePrimeManager(accountStoragePath, ctx.directory ?? 'default')
} catch (error) {
logger.warn('prime', 'failed to release slot', {
error: error instanceof Error ? error.message : String(error),
})
}
const rpcServers = (
globalThis as {
__anthropicAuthRpcServers?: Map<string, RpcServerHandle>
}
).__anthropicAuthRpcServers
if (!rpcServer || !rpcDir || rpcServers?.get(rpcDir) !== rpcServer) return
try {
await rpcServer.stop()
if (rpcServers.get(rpcDir) === rpcServer) rpcServers.delete(rpcDir)
} catch (error) {
logger.warn('rpc', 'failed to stop', {
error: error instanceof Error ? error.message : String(error),
})
}
}

// Remembers the last explicit routing decision so quota-only sidebar refreshes
// (background main/fallback quota landing) do not reset the active account.
Expand Down Expand Up @@ -7600,10 +7664,6 @@ const anthropicAuthPlugin = async (

return {}
},
dispose: async () => {
await quotaHeaderFeedRegistry?.dispose()
claustrumCredentialCache?.close()
},
methods: [
{
label: 'Claude Pro/Max',
Expand Down Expand Up @@ -7664,6 +7724,7 @@ const anthropicAuthPlugin = async (
},
],
},
dispose,
__primeManager: primeManager,
__quotaManager: quotaManager,
__persistFallbackQuotaErrorForTest: persistFallbackQuotaError,
Expand Down
21 changes: 21 additions & 0 deletions packages/opencode/src/prime-manager-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,24 @@ export function adoptPrimeManager(
})
return manager
}

// Each adopting slot owns its own release; releasing the last slot stops the
// shared manager so a disposed instance cannot leave a timer alive for a
// sibling project that still depends on the same storage path.
export function releasePrimeManager(storagePath: string, slot: string): void {
const fingerprint = primeStorageFingerprint(storagePath)
const entry = primeManagers.get(fingerprint)
if (entry) {
entry.slots.delete(slot)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a slot is re-adopted under the same storage fingerprint before an older instance disposes, this removes the successor's slot and stops its live PrimeManager. Track an adoption generation or lease and release only the matching adoption.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/prime-manager-registry.ts, line 63:

<comment>When a slot is re-adopted under the same storage fingerprint before an older instance disposes, this removes the successor's slot and stops its live `PrimeManager`. Track an adoption generation or lease and release only the matching adoption.</comment>

<file context>
@@ -52,3 +52,24 @@ export function adoptPrimeManager(
+  const fingerprint = primeStorageFingerprint(storagePath)
+  const entry = primeManagers.get(fingerprint)
+  if (entry) {
+    entry.slots.delete(slot)
+    if (entry.slots.size === 0) {
+      entry.manager.stop()
</file context>

if (entry.slots.size === 0) {
entry.manager.stop()
primeManagers.delete(fingerprint)
}
}
// A late release must not clobber a successor's slot mapping; the slot may
// already have been re-adopted under a different storage path, and the next
// adopt relies on this map to detach it from the previous owner.
if (slotFingerprints.get(slot) === fingerprint) {
slotFingerprints.delete(slot)
}
}
38 changes: 24 additions & 14 deletions packages/opencode/src/rpc/notifications.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
import { logger } from '@cortexkit/anthropic-auth-core'

import type { OpenDialogPayload, RpcNotification } from './protocol'

const QUEUE_CAP = 100
const TUI_CONNECTED_WINDOW_MS = 3_000

// One queue serves every RPC server in the process, and a process can hold one server per
// project directory. Session ids are globally unique, so a notice that carries one reaches
// only the TUI polling for that session. A notice WITHOUT one broadcasts instead: every
// draining TUI receives it and one session's ack does not prune it for the others — which,
// once a process serves more than one project, would carry it across project boundaries.
// The producer boundary therefore requires a session id; the wire field stays optional so
// an older TUI still parses what it is sent.
let queue: RpcNotification[] = []
let nextId = 1
let lastDrainAtAny = 0
const lastDrainAtBySession = new Map<string, number>()
let warnedAboutUnscopedDrain = false

export function pushNotification(
payload: OpenDialogPayload,
sessionId?: string,
sessionId: string,
): void {
queue.push({ id: nextId++, type: 'open-dialog', payload, sessionId })
if (queue.length > QUEUE_CAP) queue = queue.slice(queue.length - QUEUE_CAP)
Expand All @@ -21,34 +30,35 @@ export function drainNotifications(
sessionId?: string,
): RpcNotification[] {
const now = Date.now()
lastDrainAtAny = now
if (sessionId !== undefined) lastDrainAtBySession.set(sessionId, now)
const matches = (n: RpcNotification) =>
sessionId === undefined ||
n.sessionId === undefined ||
n.sessionId === sessionId
sessionId === undefined || n.sessionId === sessionId
if (sessionId === undefined && !warnedAboutUnscopedDrain) {
warnedAboutUnscopedDrain = true
logger.warn(
'rpc.notifications',
'drain arrived without a session id; delivery is unscoped and the queue is left intact',
)
}
if (lastReceivedId > 0) {
queue = queue.filter((n) => {
if (n.id > lastReceivedId) return true
if (sessionId === undefined) return false
if (sessionId === undefined) return true
return n.sessionId !== sessionId
})
}
return queue.filter((n) => n.id > lastReceivedId && matches(n))
}

export function isTuiConnected(sessionId?: string): boolean {
export function isTuiConnected(sessionId: string): boolean {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a legacy TUI polls without sessionId, this function leaves no connection timestamp, so command.execute.before falls back to sendIgnoredMessage instead of enqueueing the modal notification. Preserve an unscoped liveness fallback for legacy clients or explicitly remove that compatibility path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/rpc/notifications.ts, line 53:

<comment>When a legacy TUI polls without `sessionId`, this function leaves no connection timestamp, so `command.execute.before` falls back to `sendIgnoredMessage` instead of enqueueing the modal notification. Preserve an unscoped liveness fallback for legacy clients or explicitly remove that compatibility path.</comment>

<file context>
@@ -21,34 +30,35 @@ export function drainNotifications(
 }
 
-export function isTuiConnected(sessionId?: string): boolean {
+export function isTuiConnected(sessionId: string): boolean {
   const now = Date.now()
-  if (sessionId !== undefined) {
</file context>

const now = Date.now()
if (sessionId !== undefined) {
const at = lastDrainAtBySession.get(sessionId) ?? 0
return at > 0 && now - at < TUI_CONNECTED_WINDOW_MS
}
return lastDrainAtAny > 0 && now - lastDrainAtAny < TUI_CONNECTED_WINDOW_MS
const at = lastDrainAtBySession.get(sessionId) ?? 0
return at > 0 && now - at < TUI_CONNECTED_WINDOW_MS
}

export function resetNotificationsForTest(): void {
queue = []
nextId = 1
lastDrainAtAny = 0
lastDrainAtBySession.clear()
warnedAboutUnscopedDrain = false
}
15 changes: 11 additions & 4 deletions packages/opencode/src/rpc/rpc-server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { randomBytes, timingSafeEqual } from 'node:crypto'
import { unlink } from 'node:fs/promises'
import { readFile, unlink } from 'node:fs/promises'
import {
createServer,
type IncomingMessage,
Expand Down Expand Up @@ -115,9 +115,16 @@ export async function startRpcServer(
token,
async stop() {
await new Promise<void>((resolve) => server.close(() => resolve()))
await unlink(join(options.dir, `port-${process.pid}.json`)).catch(
() => {},
)
try {
const portFile = join(options.dir, `port-${process.pid}.json`)
const current = JSON.parse(await readFile(portFile, 'utf8')) as {
port?: unknown
pid?: unknown
}
if (current.port === port && current.pid === process.pid) {
await unlink(portFile)
}
} catch {}
},
}
}
Loading