chore: merge dev into websocket support

This commit is contained in:
Aiden Cline
2026-05-26 20:09:21 -05:00
158 changed files with 5455 additions and 1500 deletions
+20
View File
@@ -4,10 +4,14 @@ import {
type AgentSideConnection,
type AuthenticateRequest,
type CancelNotification,
type CloseSessionRequest,
type ForkSessionRequest,
type InitializeRequest,
type ListSessionsRequest,
type LoadSessionRequest,
type NewSessionRequest,
type PromptRequest,
type ResumeSessionRequest,
type SetSessionConfigOptionRequest,
type SetSessionModelRequest,
type SetSessionModeRequest,
@@ -44,6 +48,22 @@ export class Agent implements ACPAgent {
return run(this.service.loadSession(params))
}
listSessions(params: ListSessionsRequest) {
return run(this.service.listSessions(params))
}
resumeSession(params: ResumeSessionRequest) {
return run(this.service.resumeSession(params))
}
closeSession(params: CloseSessionRequest) {
return run(this.service.closeSession(params))
}
unstable_forkSession(params: ForkSessionRequest) {
return run(this.service.forkSession(params))
}
setSessionConfigOption(params: SetSessionConfigOptionRequest) {
return run(this.service.setSessionConfigOption(params))
}
+311
View File
@@ -0,0 +1,311 @@
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import * as Log from "@opencode-ai/core/util/log"
import type {
Event,
EventMessagePartDelta,
EventMessagePartUpdated,
OpencodeClient,
Part,
SessionMessageResponse,
ToolPart,
} from "@opencode-ai/sdk/v2"
import { Effect } from "effect"
import { ACPNextSession } from "./session"
import {
duplicateRunningToolUpdate,
errorToolUpdate,
pendingToolCall,
runningToolUpdate,
shellOutputSnapshot,
completedToolUpdate,
} from "./tool"
const log = Log.create({ service: "acp-next-event" })
type Connection = Pick<AgentSideConnection, "sessionUpdate">
type GlobalEventEnvelope = {
payload?: Event
}
type GlobalEventStream = {
stream: AsyncIterable<GlobalEventEnvelope>
}
export function start(input: { sdk: OpencodeClient; connection: Connection; session: ACPNextSession.Interface }) {
const subscription = new Subscription(input)
subscription.start()
return subscription
}
export class Subscription {
private readonly abort = new AbortController()
private readonly shellSnapshots = new Map<string, string>()
private readonly toolStarts = new Set<string>()
private started = false
constructor(
private readonly input: {
sdk: OpencodeClient
connection: Connection
session: ACPNextSession.Interface
},
) {}
start() {
if (this.started) return
this.started = true
this.run().catch((error: unknown) => {
if (this.abort.signal.aborted) return
log.error("event subscription failed", { error })
})
}
stop() {
this.abort.abort()
}
async handle(event: Event) {
switch (event.type) {
case "message.part.updated":
return this.handlePartUpdated(event)
case "message.part.delta":
return this.handlePartDelta(event)
}
}
async replayMessage(message: SessionMessageResponse) {
if (message.info.role !== "assistant" && message.info.role !== "user") return
for (const part of message.parts) {
await this.recordFetchedPart(message.info.sessionID, message, part)
if (part.type === "tool") {
await this.handleToolPart(message.info.sessionID, part)
}
}
}
private async run() {
while (!this.abort.signal.aborted) {
const events = (await this.input.sdk.global.event({
signal: this.abort.signal,
})) as GlobalEventStream
for await (const event of events.stream) {
if (this.abort.signal.aborted) return
if (!event.payload) continue
await this.handle(event.payload).catch((error: unknown) => {
log.error("failed to handle event", { error, type: event.payload?.type })
})
}
if (!this.abort.signal.aborted) await new Promise((resolve) => setTimeout(resolve, 1000))
}
}
private async handlePartUpdated(event: EventMessagePartUpdated) {
const part = event.properties.part
const sessionId = part.sessionID || event.properties.sessionID
const session = await Effect.runPromise(this.input.session.tryGet(sessionId))
if (!session) return
await Effect.runPromise(
this.input.session.recordPartMetadata({
sessionId: session.id,
messageId: part.messageID,
partId: part.id,
partType: part.type,
role: part.type === "reasoning" ? "assistant" : undefined,
ignored: part.type === "text" ? part.ignored : undefined,
toolCallId: part.type === "tool" ? part.callID : undefined,
metadata: "metadata" in part ? part.metadata : undefined,
}),
)
if (part.type === "tool") {
await this.handleToolPart(session.id, part)
}
}
private async handlePartDelta(event: EventMessagePartDelta) {
const props = event.properties
const session = await Effect.runPromise(this.input.session.tryGet(props.sessionID))
if (!session) return
const known = await Effect.runPromise(
this.input.session.tryGetPartMetadata({
sessionId: session.id,
messageId: props.messageID,
partId: props.partID,
}),
)
const metadata =
known?.role && known.partType
? known
: await this.fetchPartMetadata(session.id, session.cwd, props.messageID, props.partID)
if (metadata?.role !== "assistant") return
if (metadata.partType === "text" && props.field === "text" && metadata.ignored !== true) {
await this.input.connection.sessionUpdate({
sessionId: session.id,
update: {
sessionUpdate: "agent_message_chunk",
messageId: props.messageID,
content: {
type: "text",
text: props.delta,
},
},
})
return
}
if (metadata.partType === "reasoning" && props.field === "text") {
await this.input.connection.sessionUpdate({
sessionId: session.id,
update: {
sessionUpdate: "agent_thought_chunk",
messageId: props.messageID,
content: {
type: "text",
text: props.delta,
},
},
})
}
}
private async fetchPartMetadata(sessionId: string, cwd: string, messageId: string, partId: string) {
const message = await this.input.sdk.session
.message(
{
sessionID: sessionId,
messageID: messageId,
directory: cwd,
},
{ throwOnError: true },
)
.then((response) => response.data)
.catch((error: unknown) => {
log.error("unexpected error when fetching message for delta metadata", { error, messageId, partId })
return undefined
})
if (!message) return
const part = message.parts.find((item) => item.id === partId)
if (!part) return
return await this.recordFetchedPart(sessionId, message, part)
}
private async recordFetchedPart(sessionId: string, message: SessionMessageResponse, part: Part) {
return await Effect.runPromise(
this.input.session.recordPartMetadata({
sessionId,
messageId: part.messageID,
partId: part.id,
partType: part.type,
role: message.info.role,
ignored: part.type === "text" ? part.ignored : undefined,
toolCallId: part.type === "tool" ? part.callID : undefined,
metadata: "metadata" in part ? part.metadata : undefined,
}),
)
}
private async handleToolPart(sessionId: string, part: ToolPart) {
await this.toolStart(sessionId, part)
switch (part.state.status) {
case "pending":
this.shellSnapshots.delete(part.callID)
return
case "running":
await this.runningTool(sessionId, part)
return
case "completed":
this.clearTool(part.callID)
await this.input.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
...completedToolUpdate({
toolCallId: part.callID,
toolName: part.tool,
state: part.state,
}),
},
})
return
case "error":
this.clearTool(part.callID)
await this.input.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
...errorToolUpdate({
toolCallId: part.callID,
toolName: part.tool,
state: part.state,
}),
},
})
return
}
}
private async runningTool(sessionId: string, part: ToolPart) {
if (part.state.status !== "running") return
const output = part.tool === "bash" ? shellOutputSnapshot(part.state) : undefined
if (output !== undefined) {
if (this.shellSnapshots.get(part.callID) === output) {
await this.input.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
...duplicateRunningToolUpdate({
toolCallId: part.callID,
toolName: part.tool,
state: part.state,
}),
},
})
return
}
this.shellSnapshots.set(part.callID, output)
}
await this.input.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: part.callID,
toolName: part.tool,
state: part.state,
output,
}),
},
})
}
private async toolStart(sessionId: string, part: ToolPart) {
if (this.toolStarts.has(part.callID)) return
this.toolStarts.add(part.callID)
await this.input.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call",
...pendingToolCall({
toolCallId: part.callID,
toolName: part.tool,
}),
},
})
}
private clearTool(toolCallId: string) {
this.toolStarts.delete(toolCallId)
this.shellSnapshots.delete(toolCallId)
}
}
export * as ACPNextEvent from "./event"
+181 -12
View File
@@ -4,8 +4,14 @@ import {
type AuthenticateResponse,
type AuthMethod,
type CancelNotification,
type CloseSessionRequest,
type CloseSessionResponse,
type ForkSessionRequest,
type ForkSessionResponse,
type InitializeRequest,
type InitializeResponse,
type ListSessionsRequest,
type ListSessionsResponse,
type LoadSessionRequest,
type LoadSessionResponse,
type McpServer,
@@ -13,6 +19,9 @@ import {
type NewSessionResponse,
type PromptRequest,
type PromptResponse,
type ResumeSessionRequest,
type ResumeSessionResponse,
type SessionInfo,
type SetSessionConfigOptionRequest,
type SetSessionConfigOptionResponse,
type SetSessionModelRequest,
@@ -21,17 +30,20 @@ import {
type SetSessionModeResponse,
} from "@agentclientprotocol/sdk"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import * as Log from "@opencode-ai/core/util/log"
import type { Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
import { Context, Effect, Layer, ManagedRuntime } from "effect"
import * as ACPNextError from "./error"
import { buildConfigOptions, parseModelSelection } from "./config-option"
import { Directory } from "./directory"
import { ACPNextEvent } from "./event"
import { ACPNextSession } from "./session"
import { ModelID, ProviderID } from "@/provider/schema"
import { Provider } from "@/provider/provider"
import type { Command } from "@/command"
export const AuthMethodID = "opencode-login"
const log = Log.create({ service: "acp-next-service" })
export type Error = ACPNextError.Error
@@ -40,6 +52,10 @@ export type Interface = {
readonly authenticate: (input: AuthenticateRequest) => Effect.Effect<AuthenticateResponse, Error>
readonly newSession: (input: NewSessionRequest) => Effect.Effect<NewSessionResponse, Error>
readonly loadSession: (input: LoadSessionRequest) => Effect.Effect<LoadSessionResponse, Error>
readonly listSessions: (input: ListSessionsRequest) => Effect.Effect<ListSessionsResponse, Error>
readonly resumeSession: (input: ResumeSessionRequest) => Effect.Effect<ResumeSessionResponse, Error>
readonly closeSession: (input: CloseSessionRequest) => Effect.Effect<CloseSessionResponse, Error>
readonly forkSession: (input: ForkSessionRequest) => Effect.Effect<ForkSessionResponse, Error>
readonly setSessionConfigOption: (
input: SetSessionConfigOptionRequest,
) => Effect.Effect<SetSessionConfigOptionResponse, Error>
@@ -56,10 +72,15 @@ export function make(input: {
connection?: Pick<AgentSideConnection, "sessionUpdate">
directory?: Directory.Interface
session?: ACPNextSession.Interface
eventSubscription?: (subscription: ACPNextEvent.Subscription) => void
}): Interface {
const session = input.session ?? makeSessionService()
const directoryService = input.directory ?? makeDirectoryService(input.sdk)
const registeredMcp = new Map<string, Set<string>>()
const events = input.connection
? ACPNextEvent.start({ sdk: input.sdk, connection: input.connection, session })
: undefined
if (events) input.eventSubscription?.(events)
const initialize = Effect.fn("ACPNext.initialize")(function* (params: InitializeRequest) {
const authMethod: AuthMethod = {
@@ -90,6 +111,12 @@ export function make(input: {
embeddedContext: true,
image: true,
},
sessionCapabilities: {
close: {},
fork: {},
list: {},
resume: {},
},
},
authMethods: [authMethod],
agentInfo: {
@@ -180,6 +207,137 @@ export function make(input: {
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers)
yield* sendAvailableCommands(input.connection, state.id, snapshot)
yield* replayMessages(events, messages)
return {
configOptions: configOptions(snapshot, {
model: state.model ?? model,
variant: state.variant,
modeId: state.modeId,
}),
}
})
const listSessions = Effect.fn("ACPNext.listSessions")(function* (params: ListSessionsRequest) {
const cursor = params.cursor ? Number(params.cursor) : undefined
const limit = 100
const sessions = yield* request(
() =>
input.sdk.session.list(
{
...(params.cwd ? { directory: params.cwd } : {}),
roots: true,
},
{ throwOnError: true },
),
"session",
)
const sorted = sessions.toSorted((a, b) => b.time.updated - a.time.updated)
const filtered =
cursor === undefined || !Number.isFinite(cursor) ? sorted : sorted.filter((item) => item.time.updated < cursor)
const page = filtered.slice(0, limit)
const last = page.at(-1)
return {
sessions: page.map(
(item): SessionInfo => ({
sessionId: item.id,
cwd: item.directory,
title: item.title,
updatedAt: new Date(item.time.updated).toISOString(),
}),
),
...(filtered.length > limit && last ? { nextCursor: String(last.time.updated) } : {}),
}
})
const resumeSession = Effect.fn("ACPNext.resumeSession")(function* (params: ResumeSessionRequest) {
const snapshot = yield* directorySnapshot(params.cwd)
yield* request(
() => input.sdk.session.get({ directory: params.cwd, sessionID: params.sessionId }, { throwOnError: true }),
"session",
)
const messages = yield* request(
() =>
input.sdk.session.messages(
{ directory: params.cwd, sessionID: params.sessionId, limit: 20 },
{ throwOnError: true },
),
"session",
)
const restored = restoreFromMessages(messages.map((item) => item.info))
const model = restored.model ?? selectDefaultModel(snapshot)
const state = yield* session.load({
id: params.sessionId,
cwd: params.cwd,
mcpServers: params.mcpServers ?? [],
model,
variant: restored.variant ?? selectVariant(snapshot, model),
modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined),
})
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? [])
yield* sendAvailableCommands(input.connection, state.id, snapshot)
yield* replayMessages(events, messages)
return {
configOptions: configOptions(snapshot, {
model: state.model ?? model,
variant: state.variant,
modeId: state.modeId,
}),
}
})
const closeSession = Effect.fn("ACPNext.closeSession")(function* (params: CloseSessionRequest) {
const removed = yield* session.remove(params.sessionId)
registeredMcp.delete(params.sessionId)
if (!removed) return {}
yield* request(
() => input.sdk.session.abort({ directory: removed.cwd, sessionID: params.sessionId }, { throwOnError: true }),
"session",
).pipe(
Effect.catch((error) =>
Effect.sync(() => {
log.error("failed to abort session while closing ACP session", { error, sessionID: params.sessionId })
}),
),
)
return {}
})
const forkSession = Effect.fn("ACPNext.forkSession")(function* (params: ForkSessionRequest) {
const snapshot = yield* directorySnapshot(params.cwd)
const forked = yield* request(
() =>
input.sdk.session.fork(
{
directory: params.cwd,
sessionID: params.sessionId,
},
{ throwOnError: true },
),
"session",
)
const messages = yield* request(
() =>
input.sdk.session.messages({ directory: params.cwd, sessionID: forked.id, limit: 20 }, { throwOnError: true }),
"session",
)
const restored = restoreFromMessages(messages.map((item) => item.info))
const model = restored.model ?? selectDefaultModel(snapshot)
const state = yield* session.load({
id: forked.id,
cwd: params.cwd,
mcpServers: params.mcpServers ?? [],
model,
variant: restored.variant ?? selectVariant(snapshot, model),
modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined),
})
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? [])
yield* sendAvailableCommands(input.connection, state.id, snapshot)
yield* replayMessages(events, messages)
return {
sessionId: state.id,
@@ -278,6 +436,10 @@ export function make(input: {
authenticate,
newSession,
loadSession,
listSessions,
resumeSession,
closeSession,
forkSession,
setSessionConfigOption,
setSessionMode,
setSessionModel,
@@ -311,6 +473,17 @@ function makeDirectoryService(sdk: OpencodeClient) {
).runSync(Directory.Service.use((service) => Effect.succeed(service)))
}
function replayMessages(subscription: ACPNextEvent.Subscription | undefined, messages: SessionMessageResponse[]) {
if (!subscription) return Effect.void
return Effect.promise(async () => {
for (const message of messages) {
await subscription.replayMessage(message).catch((error: unknown) => {
log.error("failed to replay ACP message", { error, messageID: message.info.id })
})
}
})
}
type ConfigState = {
readonly model: Directory.DefaultModel
readonly variant?: string
@@ -323,17 +496,13 @@ type SdkResponse<T> = {
}
type MessageInfo = {
readonly role?: string
readonly model?: {
readonly providerID?: string
readonly modelID?: string
readonly variant?: string
}
readonly providerID?: string
readonly modelID?: string
readonly variant?: string
readonly mode?: string
readonly agent?: string
readonly role?: Message["role"]
readonly model?: Extract<Message, { role: "user" }>["model"]
readonly providerID?: Extract<Message, { role: "assistant" }>["providerID"]
readonly modelID?: Extract<Message, { role: "assistant" }>["modelID"]
readonly variant?: Extract<Message, { role: "assistant" }>["variant"]
readonly mode?: Extract<Message, { role: "assistant" }>["mode"]
readonly agent?: Message["agent"]
}
function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
+10
View File
@@ -1,4 +1,5 @@
import type { McpServer } from "@agentclientprotocol/sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2"
import { Context, Effect, Layer, Ref } from "effect"
import type { ModelID, ProviderID } from "../provider/schema"
import * as ACPNextError from "./error"
@@ -11,6 +12,9 @@ export type SelectedModel = {
export type KnownMessagePartMetadata = {
messageId: string
partId: string
partType?: Part["type"]
role?: Message["role"]
ignored?: boolean
toolCallId?: string
metadata?: unknown
}
@@ -40,6 +44,9 @@ export type RecordPartMetadataInput = {
sessionId: string
messageId: string
partId: string
partType?: Part["type"]
role?: Message["role"]
ignored?: boolean
toolCallId?: string
metadata?: unknown
}
@@ -146,6 +153,9 @@ export const layer = Layer.effect(
const metadata = {
messageId: input.messageId,
partId: input.partId,
partType: input.partType,
role: input.role,
ignored: input.ignored,
toolCallId: input.toolCallId,
metadata: input.metadata,
}
+117 -1
View File
@@ -1,4 +1,4 @@
import type { ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk"
import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk"
export type ToolInput = Record<string, unknown>
@@ -16,6 +16,19 @@ export type CompletedToolState = {
readonly attachments?: ReadonlyArray<ToolAttachment>
}
export type RunningToolState = {
readonly status: "running"
readonly input: ToolInput
readonly title?: string
}
export type ErrorToolState = {
readonly status: "error"
readonly input: ToolInput
readonly error: string
readonly metadata?: unknown
}
export type ImageAttachment = {
readonly mimeType: string
readonly data: string
@@ -100,6 +113,104 @@ export function completedToolContent(toolName: string, state: CompletedToolState
return content
}
export function pendingToolCall(input: { readonly toolCallId: string; readonly toolName: string }): ToolCall {
return {
toolCallId: input.toolCallId,
title: input.toolName,
kind: toToolKind(input.toolName),
status: "pending",
locations: [],
rawInput: {},
}
}
export function runningToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: RunningToolState
readonly output?: string
}): ToolCallUpdate {
const content = input.output
? [
{
type: "content" as const,
content: {
type: "text" as const,
text: input.output,
},
},
]
: undefined
return {
toolCallId: input.toolCallId,
status: "in_progress",
kind: toToolKind(input.toolName),
title: input.state.title ?? input.toolName,
locations: toLocations(input.toolName, input.state.input),
rawInput: input.state.input,
...(content ? { content } : {}),
}
}
export function duplicateRunningToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: RunningToolState
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
status: "in_progress",
kind: toToolKind(input.toolName),
title: input.state.title ?? input.toolName,
locations: toLocations(input.toolName, input.state.input),
rawInput: input.state.input,
}
}
export function completedToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: CompletedToolState & { readonly title: string }
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
status: "completed",
kind: toToolKind(input.toolName),
title: input.state.title,
content: completedToolContent(input.toolName, input.state),
rawInput: input.state.input,
rawOutput: completedToolRawOutput(input.state),
}
}
export function errorToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: ErrorToolState
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
status: "failed",
kind: toToolKind(input.toolName),
title: input.toolName,
rawInput: input.state.input,
content: [
{
type: "content",
content: {
type: "text",
text: input.state.error,
},
},
],
rawOutput: {
error: input.state.error,
metadata: input.state.metadata,
},
}
}
export function completedToolRawOutput(state: CompletedToolState) {
return {
output: state.output,
@@ -138,6 +249,11 @@ export const extractLocations = toLocations
export const buildCompletedToolContent = completedToolContent
export const buildCompletedRawOutput = completedToolRawOutput
export const extractShellOutputSnapshot = shellOutputSnapshot
export const buildPendingToolCall = pendingToolCall
export const buildRunningToolUpdate = runningToolUpdate
export const buildDuplicateRunningToolUpdate = duplicateRunningToolUpdate
export const buildCompletedToolUpdate = completedToolUpdate
export const buildErrorToolUpdate = errorToolUpdate
function locationFrom(value: unknown): ToolCallLocation[] {
const path = stringValue(value)
+6 -18
View File
@@ -1,5 +1,6 @@
import type { AgentSideConnection, Usage } from "@agentclientprotocol/sdk"
import * as Log from "@opencode-ai/core/util/log"
import type { AssistantMessage as OpenCodeAssistantMessage, Message } from "@opencode-ai/sdk/v2"
import { InstanceRef } from "@/effect/instance-ref"
import { InstanceStore } from "@/project/instance-store"
import { ModelID, ProviderID } from "@/provider/schema"
@@ -8,27 +9,14 @@ import { Context, Effect, Layer, SynchronizedRef } from "effect"
const log = Log.create({ service: "acp-next-usage" })
export type AssistantTokenCost = {
readonly cost: number
readonly tokens: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: {
readonly read: number
readonly write: number
}
}
}
export type AssistantTokenCost = Pick<OpenCodeAssistantMessage, "cost" | "tokens">
export type AssistantMessage = AssistantTokenCost & {
readonly role: "assistant"
readonly providerID?: string
readonly modelID?: string
}
export type AssistantMessage = AssistantTokenCost &
Pick<OpenCodeAssistantMessage, "role"> &
Partial<Pick<OpenCodeAssistantMessage, "providerID" | "modelID">>
export type SessionMessage = {
readonly info: { readonly role: string } | AssistantMessage
readonly info: { readonly role: Message["role"] } | AssistantMessage
}
export type MessagesInput = {
+197 -99
View File
@@ -3,7 +3,7 @@ import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import * as Clipboard from "@tui/util/clipboard"
import * as Selection from "@tui/util/selection"
import * as TuiAudio from "@tui/util/audio"
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
import { createCliRenderer, MouseButton, type CliRenderer, type CliRendererConfig } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import {
Switch,
@@ -18,7 +18,7 @@ import {
Show,
on,
} from "solid-js"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
import { Flag } from "@opencode-ai/core/flag/flag"
import semver from "semver"
import { DialogProvider, useDialog } from "@tui/ui/dialog"
@@ -51,7 +51,7 @@ import { PromptStashProvider } from "./component/prompt/stash"
import { DialogAlert } from "./ui/dialog-alert"
import { DialogConfirm } from "./ui/dialog-confirm"
import { ToastProvider, useToast } from "./ui/toast"
import { ExitProvider, useExit } from "./context/exit"
import { createExit, ExitProvider, useExit, type Exit } from "./context/exit"
import { Session as SessionApi } from "@/session/session"
import { TuiEvent } from "./event"
import { KVProvider, useKV } from "./context/kv"
@@ -123,7 +123,7 @@ const appBindingCommands = [
"app.toggle.session_directory_filter",
] as const
function rendererConfig(_config: TuiConfig.Resolved): CliRendererConfig {
export function tuiRendererConfig(_config: TuiConfig.Resolved): CliRendererConfig {
const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true)
return {
@@ -146,6 +146,34 @@ function rendererConfig(_config: TuiConfig.Resolved): CliRendererConfig {
}
}
export function createTuiRenderer(config: TuiConfig.Resolved) {
return createCliRenderer(tuiRendererConfig(config))
}
export type TuiHandle = {
ready: Promise<void>
done: Promise<void>
exit: Exit
}
type TuiInput = {
url: string
args: Args
config: TuiConfig.Resolved
renderer: CliRenderer
onSnapshot?: () => Promise<string[]>
directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
events?: EventSource
}
type TuiLifecycle = {
exit: Exit
exited: Promise<void>
fail(error: unknown): Promise<never>
}
function errorMessage(error: unknown) {
const formatted = FormatError(error)
if (formatted !== undefined) return formatted
@@ -163,105 +191,175 @@ function errorMessage(error: unknown) {
return FormatUnknownError(error)
}
export function tui(input: {
url: string
args: Args
config: TuiConfig.Resolved
onSnapshot?: () => Promise<string[]>
directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
events?: EventSource
}) {
// promise to prevent immediate exit
// oxlint-disable-next-line no-async-promise-executor -- intentional: async executor used for sequential setup before resolve
return new Promise<void>(async (resolve) => {
const unguard = win32InstallCtrlCGuard()
win32DisableProcessedInput()
export function tui(input: TuiInput): TuiHandle {
const unguard = win32InstallCtrlCGuard()
win32DisableProcessedInput()
const onExit = async () => {
unguard?.()
resolve()
}
const onBeforeExit = async () => {
offKeymap()
const renderer = input.renderer
const keymap = createDefaultOpenTuiKeymap(renderer)
const unregisterKeymap = registerOpencodeKeymap(keymap, renderer, input.config)
const lifecycle = createTuiLifecycle({
renderer,
unguard,
cleanup: async () => {
unregisterKeymap()
await TuiPluginRuntime.dispose()
TuiAudio.dispose()
}
const renderer = await createCliRenderer(rendererConfig(input.config))
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
void renderer.getPalette({ size: 16 }).catch(() => undefined)
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
const keymap = createDefaultOpenTuiKeymap(renderer)
const offKeymap = registerOpencodeKeymap(keymap, renderer, input.config)
await render(() => {
return (
<ErrorBoundary
fallback={(error, reset) => (
<ErrorComponent error={error} reset={reset} onBeforeExit={onBeforeExit} onExit={onExit} mode={mode} />
)}
>
<OpencodeKeymapProvider keymap={keymap}>
<ArgsProvider {...input.args}>
<ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
<KVProvider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<TuiConfigProvider config={input.config}>
<SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<ProjectProvider>
<SyncProvider>
<SyncProviderV2>
<ThemeProvider mode={mode}>
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<App onSnapshot={input.onSnapshot} />
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</SyncProviderV2>
</SyncProvider>
</ProjectProvider>
</SDKProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</ExitProvider>
</ArgsProvider>
</OpencodeKeymapProvider>
</ErrorBoundary>
)
}, renderer)
},
})
const ready = mountTui({ ...input, keymap, exit: lifecycle.exit }).catch((error) => lifecycle.fail(error))
const done = waitUntilDone(ready, lifecycle.exited)
return { ready, done, exit: lifecycle.exit }
}
async function mountTui(input: TuiInput & { keymap: ReturnType<typeof createDefaultOpenTuiKeymap>; exit: Exit }) {
const renderer = input.renderer
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
void renderer.getPalette({ size: 16 }).catch(() => undefined)
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
if (renderer.isDestroyed) return
await render(() => {
return (
<ErrorBoundary
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} exit={input.exit} mode={mode} />}
>
<OpencodeKeymapProvider keymap={input.keymap}>
<ArgsProvider {...input.args}>
<ExitProvider exit={input.exit}>
<KVProvider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<TuiConfigProvider config={input.config}>
<SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<ProjectProvider>
<SyncProvider>
<SyncProviderV2>
<ThemeProvider mode={mode}>
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<App onSnapshot={input.onSnapshot} />
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</SyncProviderV2>
</SyncProvider>
</ProjectProvider>
</SDKProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</ExitProvider>
</ArgsProvider>
</OpencodeKeymapProvider>
</ErrorBoundary>
)
}, renderer)
}
function createTuiLifecycle(input: {
renderer: CliRenderer
unguard?: () => void
cleanup: () => Promise<void>
}): TuiLifecycle {
let resolveExited!: () => void
const exited = new Promise<void>((resolve) => {
resolveExited = resolve
})
let exitCompleted = false
let exiting = false
let cleanupTask: Promise<void> | undefined
const completeExit = () => {
if (exitCompleted) return
exitCompleted = true
resolveExited()
}
const cleanup = () => {
cleanupTask ??= (async () => {
process.off("SIGHUP", onSighup)
try {
await input.cleanup()
} finally {
input.unguard?.()
}
})()
return cleanupTask
}
const exit = createExit(async (reason, message) => {
exiting = true
await cleanup()
if (!input.renderer.isDestroyed) {
input.renderer.setTerminalTitle("")
input.renderer.destroy()
}
win32FlushInputBuffer()
if (reason) {
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
if (formatted) process.stderr.write(formatted + "\n")
}
const text = message()
if (text) process.stdout.write(text + "\n")
completeExit()
})
const onSighup = () => {
void exit()
}
input.renderer.once("destroy", () => {
if (exiting) return
void cleanup().finally(() => {
win32FlushInputBuffer()
completeExit()
})
})
process.on("SIGHUP", onSighup)
return {
exit,
exited,
async fail(error) {
exiting = true
await cleanup().catch(() => {})
if (!input.renderer.isDestroyed) input.renderer.destroy()
completeExit()
throw error
},
}
}
async function waitUntilDone(ready: Promise<void>, exited: Promise<void>) {
await ready
await exited
}
function App(props: { onSnapshot?: () => Promise<string[]> }) {
+5 -2
View File
@@ -67,7 +67,6 @@ export const AttachCommand = cmd({
})()
const headers = ServerAuth.headers({ password: args.password, username: args.username })
const config = await TuiConfig.get()
const { tui } = await import("./app")
try {
await validateSession({
@@ -82,9 +81,12 @@ export const AttachCommand = cmd({
return
}
await tui({
const { createTuiRenderer, tui } = await import("./app")
const renderer = await createTuiRenderer(config)
const handle = tui({
url: args.url,
config,
renderer,
args: {
continue: args.continue,
sessionID: args.session,
@@ -93,6 +95,7 @@ export const AttachCommand = cmd({
directory,
headers,
})
await handle.done
} finally {
unguard?.()
}
@@ -262,6 +262,13 @@ function AutoMethod(props: AutoMethodProps) {
method: props.index,
})
if (result.error) {
toast.show({
variant: "error",
message:
"name" in result.error && result.error.name === "ProviderAuthOauthCallbackFailed"
? "OAuth authorization failed. Try /connect again."
: JSON.stringify(result.error),
})
dialog.clear()
return
}
@@ -1,32 +1,21 @@
import { TextAttributes } from "@opentui/core"
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import * as Clipboard from "@tui/util/clipboard"
import { createSignal } from "solid-js"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { win32FlushInputBuffer } from "../win32"
import { getScrollAcceleration } from "../util/scroll"
export function ErrorComponent(props: {
error: Error
reset: () => void
onBeforeExit?: () => Promise<void>
onExit: () => Promise<void>
exit: () => Promise<void>
mode?: "dark" | "light"
}) {
const term = useTerminalDimensions()
const renderer = useRenderer()
const handleExit = async () => {
await props.onBeforeExit?.()
renderer.setTerminalTitle("")
renderer.destroy()
win32FlushInputBuffer()
await props.onExit()
}
useKeyboard((evt) => {
if (evt.ctrl && evt.name === "c") {
void handleExit()
void props.exit()
}
})
const [copied, setCopied] = createSignal(false)
@@ -79,7 +68,7 @@ export function ErrorComponent(props: {
<box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}>
<text fg={colors.bg}>Reset TUI</text>
</box>
<box onMouseUp={handleExit} backgroundColor={colors.primary} padding={1}>
<box onMouseUp={() => void props.exit()} backgroundColor={colors.primary} padding={1}>
<text fg={colors.bg}>Exit</text>
</box>
</box>
@@ -1,8 +1,6 @@
import { useRenderer } from "@opentui/solid"
import { createSimpleContext } from "./helper"
import { FormatError, FormatUnknownError } from "@/cli/error"
import { win32FlushInputBuffer } from "../win32"
type Exit = ((reason?: unknown) => Promise<void>) & {
export type Exit = ((reason?: unknown) => Promise<void>) & {
message: {
set: (value?: string) => () => void
clear: () => void
@@ -10,51 +8,35 @@ type Exit = ((reason?: unknown) => Promise<void>) & {
}
}
export function createExit(run: (reason: unknown | undefined, message: () => string | undefined) => Promise<void>) {
let message: string | undefined
let task: Promise<void> | undefined
const store = {
set: (value?: string) => {
const prev = message
message = value
return () => {
message = prev
}
},
clear: () => {
message = undefined
},
get: () => message,
}
return Object.assign(
(reason?: unknown) => {
task ??= run(reason, store.get)
return task
},
{
message: store,
},
) satisfies Exit
}
export const { use: useExit, provider: ExitProvider } = createSimpleContext({
name: "Exit",
init: (input: { onBeforeExit?: () => Promise<void>; onExit?: () => Promise<void> }) => {
const renderer = useRenderer()
let message: string | undefined
let task: Promise<void> | undefined
const store = {
set: (value?: string) => {
const prev = message
message = value
return () => {
message = prev
}
},
clear: () => {
message = undefined
},
get: () => message,
}
const exit: Exit = Object.assign(
(reason?: unknown) => {
if (task) return task
task = (async () => {
await input.onBeforeExit?.()
// Reset window title before destroying renderer
renderer.setTerminalTitle("")
renderer.destroy()
win32FlushInputBuffer()
if (reason) {
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
if (formatted) {
process.stderr.write(formatted + "\n")
}
}
const text = store.get()
if (text) process.stdout.write(text + "\n")
await input.onExit?.()
})()
return task
},
{
message: store,
},
)
process.on("SIGHUP", () => exit())
return exit
},
init: (input: { exit: Exit }) => input.exit,
})
@@ -11,6 +11,7 @@ import { createEffect, createMemo, createResource, createSignal, For, Match, onC
import { DiffViewerFileTree } from "./diff-viewer-file-tree"
import { Panel, PanelGroup, Separator } from "./diff-viewer-ui"
import { DialogSelect } from "@tui/ui/dialog-select"
import { getScrollAcceleration } from "@tui/util/scroll"
import {
allExpandedFileTreeDirectories,
buildFileTree,
@@ -133,6 +134,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
const [activePatchFileIndex, setActivePatchFileIndex] = createSignal<number | undefined>()
const [selectedFileIndex, setSelectedFileIndex] = createSignal<number | undefined>()
const [reviewedFileNames, setReviewedFileNames] = createSignal<ReadonlySet<string>>(new Set())
const patchScrollAcceleration = createMemo(() => getScrollAcceleration(props.api.tuiConfig))
const fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes()))
const patchFileIndexes = createMemo(() => orderedPatchFileIndexes(flattenFileTree(fileTree())))
const focusRunner = (input: Record<DiffViewerFocus, () => void>) => () => input[focus()]()
@@ -713,6 +715,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
ref={(element: ScrollBoxRenderable) => (scroll = element)}
flexGrow={1}
minHeight={0}
scrollAcceleration={patchScrollAcceleration()}
verticalScrollbarOptions={{ visible: false }}
horizontalScrollbarOptions={{ visible: false }}
>
+5 -2
View File
@@ -228,9 +228,11 @@ export const TuiThreadCommand = cmd({
}, 1000).unref?.()
try {
const { tui } = await import("./app")
await tui({
const { createTuiRenderer, tui } = await import("./app")
const renderer = await createTuiRenderer(config)
const handle = tui({
url: transport.url,
renderer,
async onSnapshot() {
const tui = writeHeapSnapshot("tui.heapsnapshot")
const server = await client.call("snapshot", undefined)
@@ -249,6 +251,7 @@ export const TuiThreadCommand = cmd({
fork: args.fork,
},
})
await handle.done
} finally {
await stop()
}
+8 -1
View File
@@ -762,7 +762,14 @@ export const layer = Layer.effect(
result.permission = mergeDeep(perms, result.permission ?? {})
}
if (!result.username) result.username = os.userInfo().username
if (!result.username) {
try {
result.username = os.userInfo().username || "user"
} catch (err) {
log.warn("failed to read system username, using fallback", { err })
result.username = "user"
}
}
if (result.autoshare === true && !result.share) {
result.share = "auto"
+8 -1
View File
@@ -46,7 +46,14 @@ export function parseManagedPlist(json: string): string {
export async function readManagedPreferences() {
if (process.platform !== "darwin") return
const user = os.userInfo().username
const user = (() => {
try {
return os.userInfo().username || "user"
} catch (err) {
log.warn("failed to read system username, using fallback", { err })
return "user"
}
})()
const paths = [
path.join("/Library/Managed Preferences", user, `${MANAGED_PLIST_DOMAIN}.plist`),
path.join("/Library/Managed Preferences", `${MANAGED_PLIST_DOMAIN}.plist`),
+12 -2
View File
@@ -234,6 +234,7 @@ interface AuthResult {
// --- Effect Service ---
interface State {
config: Record<string, ConfigMCP.Info>
status: Record<string, Status>
clients: Record<string, MCPClient>
defs: Record<string, MCPToolDef[]>
@@ -525,6 +526,7 @@ export const layer = Layer.effect(
const bridge = yield* EffectBridge.make()
const config = cfg.mcp ?? {}
const s: State = {
config: {},
status: {},
clients: {},
defs: {},
@@ -619,6 +621,10 @@ export const layer = Layer.effect(
result[key] = s.status[key] ?? { status: "disabled" }
}
for (const key of Object.keys(s.config)) {
result[key] = s.status[key] ?? { status: "disabled" }
}
return result
})
@@ -642,8 +648,9 @@ export const layer = Layer.effect(
})
const add = Effect.fn("MCP.add")(function* (name: string, mcp: ConfigMCP.Info) {
yield* createAndStore(name, mcp)
const s = yield* InstanceState.get(state)
s.config[name] = mcp
yield* createAndStore(name, mcp)
return { status: s.status }
})
@@ -677,7 +684,7 @@ export const layer = Layer.effect(
([clientName, client]) =>
Effect.gen(function* () {
const mcpConfig = config[clientName]
const entry = mcpConfig && isMcpConfigured(mcpConfig) ? mcpConfig : undefined
const entry = mcpConfig && isMcpConfigured(mcpConfig) ? mcpConfig : s.config[clientName]
const listed = s.defs[clientName]
if (!listed) {
@@ -756,6 +763,9 @@ export const layer = Layer.effect(
})
const getMcpConfig = Effect.fnUntraced(function* (mcpName: string) {
const s = yield* InstanceState.get(state)
if (s.config[mcpName]) return s.config[mcpName]
const cfg = yield* cfgSvc.get()
const mcpConfig = cfg.mcp?.[mcpName]
if (!mcpConfig || !isMcpConfigured(mcpConfig)) return undefined
+17 -37
View File
@@ -3,18 +3,20 @@ import type { Model } from "@opencode-ai/sdk/v2"
import * as Log from "@opencode-ai/core/util/log"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { createServer } from "http"
import open from "open"
const log = Log.create({ service: "plugin.digitalocean" })
const DO_OAUTH_CLIENT_ID = "b1a6c5158156caac821fd1b30253ca8acb52454a48fa744420e41889cb589f82"
const DO_AUTHORIZE_URL = "https://cloud.digitalocean.com/v1/oauth/authorize"
const DO_API_BASE = "https://api.digitalocean.com"
const DO_GENAI_API = `${DO_API_BASE}/v2/gen-ai`
const DO_INFERENCE_BASE = "https://inference.do-ai.run/v1"
const OAUTH_PORT = 1456
const OAUTH_REDIRECT_PATH = "/auth/callback"
const OAUTH_TOKEN_PATH = "/auth/token"
const ROUTER_REFRESH_INTERVAL_MS = 5 * 60 * 1000
const MAK_NAME_PREFIX = "opencode-oauth"
const OAUTH_SCOPES = "genai:read inference:query"
interface ImplicitTokenPayload {
access_token: string
@@ -28,12 +30,6 @@ interface PendingOAuth {
reject: (error: Error) => void
}
interface ApiKeyInfo {
uuid: string
name: string
secret_key: string
}
interface RouterEntry {
name: string
uuid?: string
@@ -59,7 +55,7 @@ function buildAuthorizeUrl(state: string): string {
response_type: "token",
client_id: DO_OAUTH_CLIENT_ID,
redirect_uri: redirectUri(),
scope: "genai:create genai:read",
scope: OAUTH_SCOPES,
state,
})
return `${DO_AUTHORIZE_URL}?${params.toString()}`
@@ -91,15 +87,20 @@ const HTML_CALLBACK = `<!doctype html>
const errorDescription = params.get("error_description") || search.get("error_description")
const titleEl = document.getElementById("title")
const msgEl = document.getElementById("msg")
const tokenUrl = new URL(${JSON.stringify(OAUTH_TOKEN_PATH)}, window.location.origin).href
try {
const body = error
? { error, error_description: errorDescription || "" }
: { access_token: params.get("access_token") || "", expires_in: params.get("expires_in") || "0", state: params.get("state") || "" }
await fetch(${JSON.stringify(OAUTH_TOKEN_PATH)}, {
const res = await fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (!res.ok) {
const detail = await res.text().catch(function () { return "" })
throw new Error(detail || ("callback failed (" + res.status + ")"))
}
if (error) {
titleEl.textContent = "Authorization Failed"
msgEl.textContent = errorDescription || error
@@ -225,31 +226,10 @@ function waitForOAuthCallback(state: string): Promise<ImplicitTokenPayload> {
})
}
async function createModelAccessKey(bearer: string): Promise<ApiKeyInfo> {
// Suffix-on-collision strategy keeps re-`/connect` non-destructive.
const name = `${MAK_NAME_PREFIX}-${Math.floor(Date.now() / 1000)}`
const res = await fetch(`${DO_API_BASE}/v2/gen-ai/models/api_keys`, {
method: "POST",
headers: {
Authorization: `Bearer ${bearer}`,
"Content-Type": "application/json",
"User-Agent": `opencode/${InstallationVersion}`,
},
body: JSON.stringify({ name }),
})
if (!res.ok) {
const body = await res.text().catch(() => "")
throw new Error(`Failed to create Model Access Key (${res.status}): ${body}`)
}
const data = (await res.json()) as { api_key_info?: ApiKeyInfo }
if (!data.api_key_info?.secret_key) throw new Error("Model Access Key response missing secret_key")
return data.api_key_info
}
async function listRouters(
bearer: string,
): Promise<{ ok: true; routers: RouterEntry[] } | { ok: false; status: number }> {
const res = await fetch(`${DO_API_BASE}/v2/gen-ai/models/routers`, {
const res = await fetch(`${DO_GENAI_API}/models/routers`, {
headers: {
Authorization: `Bearer ${bearer}`,
Accept: "application/json",
@@ -362,15 +342,16 @@ export async function DigitalOceanAuthPlugin(input: PluginInput): Promise<Hooks>
await startOAuthServer()
const state = generateState()
const callbackPromise = waitForOAuthCallback(state)
const url = buildAuthorizeUrl(state)
await open(url).catch(() => undefined)
return {
url: buildAuthorizeUrl(state),
url,
instructions:
"Sign in to DigitalOcean in your browser. OpenCode will create a Model Access Key named opencode-oauth-* and load your Inference Routers. Re-run /connect to refresh routers later.",
"Sign in to DigitalOcean in your browser. OpenCode will use your DigitalOcean API token directly for inference and load your Inference Routers. Re-run /connect to refresh routers later.",
method: "auto" as const,
async callback() {
try {
const tokens = await callbackPromise
const apiKeyInfo = await createModelAccessKey(tokens.access_token)
const routerResult = await listRouters(tokens.access_token)
const routers = routerResult.ok ? routerResult.routers : []
if (!routerResult.ok) {
@@ -379,12 +360,11 @@ export async function DigitalOceanAuthPlugin(input: PluginInput): Promise<Hooks>
return {
type: "success" as const,
provider: "digitalocean",
key: apiKeyInfo.secret_key,
key: tokens.access_token,
metadata: {
mak_uuid: apiKeyInfo.uuid,
mak_name: apiKeyInfo.name,
oauth_access: tokens.access_token,
oauth_expires: String(Date.now() + tokens.expires_in * 1000),
oauth_scopes: OAUTH_SCOPES,
routers: JSON.stringify(
routers.map((r) => ({ name: r.name, uuid: r.uuid, description: r.description })),
),
+1 -1
View File
@@ -644,7 +644,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
if (input.model.providerID !== "openai") return
output.headers.originator = "opencode"
output.headers["User-Agent"] = `opencode/${InstallationVersion} (${os.platform()} ${os.release()}; ${os.arch()})`
output.headers.session_id = input.sessionID
output.headers["session-id"] = input.sessionID
// Temporary fetch-layer hack: title generation currently shares the conversation
// session ID, so the OpenAI plugin marks it for HTTP fallback until transport
// context can be passed directly instead of smuggled through headers.
@@ -151,7 +151,7 @@ function invalidate(entry: PoolEntry) {
}
function poolKey(headers: Record<string, string>) {
const sessionID = headers["x-session-affinity"] ?? headers["session_id"]
const sessionID = headers["x-session-affinity"] ?? headers["session-id"]
if (!sessionID) return undefined
return [