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
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)`,
)
}
55 changes: 44 additions & 11 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 @@ -2806,27 +2806,63 @@ 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)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
} 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()
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
} 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),
})
}
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 +7636,6 @@ const anthropicAuthPlugin = async (

return {}
},
dispose: async () => {
await quotaHeaderFeedRegistry?.dispose()
claustrumCredentialCache?.close()
},
methods: [
{
label: 'Claude Pro/Max',
Expand Down Expand Up @@ -7664,6 +7696,7 @@ const anthropicAuthPlugin = async (
},
],
},
dispose,
__primeManager: primeManager,
__quotaManager: quotaManager,
__persistFallbackQuotaErrorForTest: persistFallbackQuotaError,
Expand Down
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
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

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 {
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

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 an older TUI polls without sessionId, isTuiConnected now always reports that session as disconnected, so command.execute.before sends an ignored text message instead of the RPC modal. Preserve a deliberate compatibility path for unscoped clients, or stop advertising the optional wire field as backward-compatible.

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 57:

<comment>When an older TUI polls without `sessionId`, `isTuiConnected` now always reports that session as disconnected, so `command.execute.before` sends an ignored text message instead of the RPC modal. Preserve a deliberate compatibility path for unscoped clients, or stop advertising the optional wire field as backward-compatible.</comment>

<file context>
@@ -54,18 +52,14 @@ export function drainNotifications(
-    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
 }
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Checked this one against the call sites rather than the diff, and the behaviour is unchanged — the branch that was removed was already unreachable in production.

isTuiConnected has exactly two callers, and neither can pass undefined:

  • index.ts:3334queueDesktopNotice(sessionId: string, …) calls isTuiConnected(sessionId); the parameter is a required string.
  • index.ts:4559isTuiConnected(input.sessionID), immediately followed by pushNotification(payload, input.sessionID). pushNotification now takes sessionId: string, so if input.sessionID were string | undefined at that point the build would fail; typecheck passing is the proof that it is narrowed to string there.

So the old lastDrainAtAny fallback could only ever have been reached by a caller that does not exist. Before the change an older TUI polling without a session id got false from the per-session map (an unscoped drain never populates lastDrainAtBySession); after the change it gets false from the same map. Same answer, one less way to get a wrong one — and the wrong one was cross-project: lastDrainAtAny was written by every project's drain, so an unscoped call could report project A's TUI connected because project B's polled, and queueDesktopNotice skips the desktop fallback when it believes a TUI is present. That is a silent no-notification, which is the symptom this PR exists to fix.

On the second half — the optional wire field — those are different fields and the PR body should have distinguished them, so: RpcNotification.sessionId (server → TUI, in the notification payload) stays optional, which is what keeps an older TUI parsing what it is sent. The drain request parameter and pushNotification's argument are the ones now required. The compatibility claim is about the former only; no client is required to change to keep receiving notices.

No code change for this thread. The unscoped-drain path itself is still deliberately supported and non-destructive — it delivers everything above the ack cursor and prunes nothing — which is the compatibility path for any client that omits the id.

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