Use v2 OpencodeClient with normalized request handling and rehydrate pending permissions via GET /permission on instance hydration.
382 lines
9.4 KiB
TypeScript
382 lines
9.4 KiB
TypeScript
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
|
import { instances } from "./instances"
|
|
|
|
import { addRecentModelPreference, setAgentModelPreference } from "./preferences"
|
|
import { sessions, withSession } from "./session-state"
|
|
import { getDefaultModel, isModelValid } from "./session-models"
|
|
import { updateSessionInfo } from "./message-v2/session-info"
|
|
import { messageStoreBus } from "./message-v2/bus"
|
|
import { getLogger } from "../lib/logger"
|
|
import { requestData } from "../lib/opencode-api"
|
|
|
|
const log = getLogger("actions")
|
|
|
|
const ID_LENGTH = 26
|
|
const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
|
|
let lastTimestamp = 0
|
|
let localCounter = 0
|
|
|
|
function randomBase62(length: number): string {
|
|
let result = ""
|
|
const cryptoObj = (globalThis as unknown as { crypto?: Crypto }).crypto
|
|
if (cryptoObj && typeof cryptoObj.getRandomValues === "function") {
|
|
const bytes = new Uint8Array(length)
|
|
cryptoObj.getRandomValues(bytes)
|
|
for (let i = 0; i < length; i++) {
|
|
result += BASE62_CHARS[bytes[i] % BASE62_CHARS.length]
|
|
}
|
|
} else {
|
|
for (let i = 0; i < length; i++) {
|
|
const idx = Math.floor(Math.random() * BASE62_CHARS.length)
|
|
result += BASE62_CHARS[idx]
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
function createId(prefix: string): string {
|
|
const timestamp = Date.now()
|
|
if (timestamp !== lastTimestamp) {
|
|
lastTimestamp = timestamp
|
|
localCounter = 0
|
|
}
|
|
localCounter++
|
|
|
|
const value = (BigInt(timestamp) << BigInt(12)) + BigInt(localCounter)
|
|
const bytes = new Array<number>(6)
|
|
for (let i = 0; i < 6; i++) {
|
|
const shift = BigInt(8 * (5 - i))
|
|
bytes[i] = Number((value >> shift) & BigInt(0xff))
|
|
}
|
|
const hex = bytes.map((b) => b.toString(16).padStart(2, "0")).join("")
|
|
const random = randomBase62(ID_LENGTH - 12)
|
|
|
|
return `${prefix}_${hex}${random}`
|
|
}
|
|
|
|
async function sendMessage(
|
|
instanceId: string,
|
|
sessionId: string,
|
|
prompt: string,
|
|
attachments: any[] = [],
|
|
): Promise<void> {
|
|
const instance = instances().get(instanceId)
|
|
if (!instance || !instance.client) {
|
|
throw new Error("Instance not ready")
|
|
}
|
|
|
|
const instanceSessions = sessions().get(instanceId)
|
|
const session = instanceSessions?.get(sessionId)
|
|
if (!session) {
|
|
throw new Error("Session not found")
|
|
}
|
|
|
|
const messageId = createId("msg")
|
|
const textPartId = createId("part")
|
|
|
|
const resolvedPrompt = resolvePastedPlaceholders(prompt, attachments)
|
|
|
|
const optimisticParts: any[] = [
|
|
{
|
|
id: textPartId,
|
|
type: "text" as const,
|
|
text: resolvedPrompt,
|
|
synthetic: true,
|
|
renderCache: undefined,
|
|
},
|
|
]
|
|
|
|
const requestParts: any[] = [
|
|
{
|
|
id: textPartId,
|
|
type: "text" as const,
|
|
text: resolvedPrompt,
|
|
},
|
|
]
|
|
|
|
if (attachments.length > 0) {
|
|
for (const att of attachments) {
|
|
const source = att.source
|
|
if (source.type === "file") {
|
|
const partId = createId("part")
|
|
requestParts.push({
|
|
id: partId,
|
|
type: "file" as const,
|
|
url: att.url,
|
|
mime: source.mime,
|
|
filename: att.filename,
|
|
})
|
|
optimisticParts.push({
|
|
id: partId,
|
|
type: "file" as const,
|
|
url: att.url,
|
|
mime: source.mime,
|
|
filename: att.filename,
|
|
synthetic: true,
|
|
})
|
|
} else if (source.type === "text") {
|
|
const display: string | undefined = att.display
|
|
const value: unknown = source.value
|
|
const isPastedPlaceholder = typeof display === "string" && /^pasted #\d+/.test(display)
|
|
|
|
if (isPastedPlaceholder || typeof value !== "string") {
|
|
continue
|
|
}
|
|
|
|
const partId = createId("part")
|
|
requestParts.push({
|
|
id: partId,
|
|
type: "text" as const,
|
|
text: value,
|
|
})
|
|
optimisticParts.push({
|
|
id: partId,
|
|
type: "text" as const,
|
|
text: value,
|
|
synthetic: true,
|
|
renderCache: undefined,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
const store = messageStoreBus.getOrCreate(instanceId)
|
|
const createdAt = Date.now()
|
|
|
|
store.upsertMessage({
|
|
id: messageId,
|
|
sessionId,
|
|
role: "user",
|
|
status: "sending",
|
|
parts: optimisticParts,
|
|
createdAt,
|
|
updatedAt: createdAt,
|
|
isEphemeral: true,
|
|
})
|
|
|
|
withSession(instanceId, sessionId, () => {
|
|
/* trigger reactivity for legacy session data */
|
|
})
|
|
|
|
const requestBody = {
|
|
messageID: messageId,
|
|
parts: requestParts,
|
|
...(session.agent && { agent: session.agent }),
|
|
...(session.model.providerId &&
|
|
session.model.modelId && {
|
|
model: {
|
|
providerID: session.model.providerId,
|
|
modelID: session.model.modelId,
|
|
},
|
|
}),
|
|
}
|
|
|
|
log.info("sendMessage", {
|
|
instanceId,
|
|
sessionId,
|
|
requestBody,
|
|
})
|
|
|
|
try {
|
|
log.info("session.promptAsync", { instanceId, sessionId, requestBody })
|
|
await requestData(
|
|
instance.client.session.promptAsync({
|
|
sessionID: sessionId,
|
|
...(requestBody as any),
|
|
}),
|
|
"session.promptAsync",
|
|
)
|
|
} catch (error) {
|
|
log.error("Failed to send prompt", error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async function executeCustomCommand(
|
|
instanceId: string,
|
|
sessionId: string,
|
|
commandName: string,
|
|
args: string,
|
|
): Promise<void> {
|
|
const instance = instances().get(instanceId)
|
|
if (!instance || !instance.client) {
|
|
throw new Error("Instance not ready")
|
|
}
|
|
|
|
const session = sessions().get(instanceId)?.get(sessionId)
|
|
if (!session) {
|
|
throw new Error("Session not found")
|
|
}
|
|
|
|
const body: {
|
|
command: string
|
|
arguments: string
|
|
messageID: string
|
|
agent?: string
|
|
model?: string
|
|
} = {
|
|
command: commandName,
|
|
arguments: args,
|
|
messageID: createId("msg"),
|
|
}
|
|
|
|
if (session.agent) {
|
|
body.agent = session.agent
|
|
}
|
|
|
|
if (session.model.providerId && session.model.modelId) {
|
|
body.model = `${session.model.providerId}/${session.model.modelId}`
|
|
}
|
|
|
|
await requestData(
|
|
instance.client.session.command({
|
|
sessionID: sessionId,
|
|
...(body as any),
|
|
}),
|
|
"session.command",
|
|
)
|
|
}
|
|
|
|
async function runShellCommand(instanceId: string, sessionId: string, command: string): Promise<void> {
|
|
const instance = instances().get(instanceId)
|
|
if (!instance || !instance.client) {
|
|
throw new Error("Instance not ready")
|
|
}
|
|
|
|
const session = sessions().get(instanceId)?.get(sessionId)
|
|
if (!session) {
|
|
throw new Error("Session not found")
|
|
}
|
|
|
|
const agent = session.agent || "build"
|
|
|
|
await requestData(
|
|
instance.client.session.shell({
|
|
sessionID: sessionId,
|
|
agent,
|
|
command,
|
|
}),
|
|
"session.shell",
|
|
)
|
|
}
|
|
|
|
async function abortSession(instanceId: string, sessionId: string): Promise<void> {
|
|
const instance = instances().get(instanceId)
|
|
if (!instance || !instance.client) {
|
|
throw new Error("Instance not ready")
|
|
}
|
|
|
|
log.info("abortSession", { instanceId, sessionId })
|
|
|
|
try {
|
|
log.info("session.abort", { instanceId, sessionId })
|
|
await requestData(
|
|
instance.client.session.abort({
|
|
sessionID: sessionId,
|
|
}),
|
|
"session.abort",
|
|
)
|
|
log.info("abortSession complete", { instanceId, sessionId })
|
|
} catch (error) {
|
|
log.error("Failed to abort session", error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async function updateSessionAgent(instanceId: string, sessionId: string, agent: string): Promise<void> {
|
|
const instanceSessions = sessions().get(instanceId)
|
|
const session = instanceSessions?.get(sessionId)
|
|
if (!session) {
|
|
throw new Error("Session not found")
|
|
}
|
|
|
|
const nextModel = await getDefaultModel(instanceId, agent)
|
|
const shouldApplyModel = isModelValid(instanceId, nextModel)
|
|
|
|
withSession(instanceId, sessionId, (current) => {
|
|
current.agent = agent
|
|
if (shouldApplyModel) {
|
|
current.model = nextModel
|
|
}
|
|
})
|
|
|
|
if (agent && shouldApplyModel) {
|
|
await setAgentModelPreference(instanceId, agent, nextModel)
|
|
}
|
|
|
|
if (shouldApplyModel) {
|
|
updateSessionInfo(instanceId, sessionId)
|
|
}
|
|
}
|
|
|
|
async function updateSessionModel(
|
|
instanceId: string,
|
|
sessionId: string,
|
|
model: { providerId: string; modelId: string },
|
|
): Promise<void> {
|
|
const instanceSessions = sessions().get(instanceId)
|
|
const session = instanceSessions?.get(sessionId)
|
|
if (!session) {
|
|
throw new Error("Session not found")
|
|
}
|
|
|
|
if (!isModelValid(instanceId, model)) {
|
|
log.warn("Invalid model selection", model)
|
|
return
|
|
}
|
|
|
|
withSession(instanceId, sessionId, (current) => {
|
|
current.model = model
|
|
})
|
|
|
|
if (session.agent) {
|
|
await setAgentModelPreference(instanceId, session.agent, model)
|
|
}
|
|
addRecentModelPreference(model)
|
|
|
|
updateSessionInfo(instanceId, sessionId)
|
|
}
|
|
|
|
async function renameSession(instanceId: string, sessionId: string, nextTitle: string): Promise<void> {
|
|
const instance = instances().get(instanceId)
|
|
if (!instance || !instance.client) {
|
|
throw new Error("Instance not ready")
|
|
}
|
|
|
|
const session = sessions().get(instanceId)?.get(sessionId)
|
|
if (!session) {
|
|
throw new Error("Session not found")
|
|
}
|
|
|
|
const trimmedTitle = nextTitle.trim()
|
|
if (!trimmedTitle) {
|
|
throw new Error("Session title is required")
|
|
}
|
|
|
|
await requestData(
|
|
instance.client.session.update({
|
|
sessionID: sessionId,
|
|
title: trimmedTitle,
|
|
}),
|
|
"session.update",
|
|
)
|
|
|
|
withSession(instanceId, sessionId, (current) => {
|
|
current.title = trimmedTitle
|
|
const time = { ...(current.time ?? {}) }
|
|
time.updated = Date.now()
|
|
current.time = time
|
|
})
|
|
}
|
|
|
|
export {
|
|
abortSession,
|
|
executeCustomCommand,
|
|
renameSession,
|
|
runShellCommand,
|
|
sendMessage,
|
|
updateSessionAgent,
|
|
updateSessionModel,
|
|
}
|