Merge branch 'dev' into sqlite2
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.1.36",
|
||||
"version": "1.1.40",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import path from "path"
|
||||
import { Global } from "../global"
|
||||
import fs from "fs/promises"
|
||||
import z from "zod"
|
||||
|
||||
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
|
||||
@@ -59,15 +58,13 @@ export namespace Auth {
|
||||
export async function set(key: string, info: Info) {
|
||||
const file = Bun.file(filepath)
|
||||
const data = await all()
|
||||
await Bun.write(file, JSON.stringify({ ...data, [key]: info }, null, 2))
|
||||
await fs.chmod(file.name!, 0o600)
|
||||
await Bun.write(file, JSON.stringify({ ...data, [key]: info }, null, 2), { mode: 0o600 })
|
||||
}
|
||||
|
||||
export async function remove(key: string) {
|
||||
const file = Bun.file(filepath)
|
||||
const data = await all()
|
||||
delete data[key]
|
||||
await Bun.write(file, JSON.stringify(data, null, 2))
|
||||
await fs.chmod(file.name!, 0o600)
|
||||
await Bun.write(file, JSON.stringify(data, null, 2), { mode: 0o600 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { TextAttributes, RGBA } from "@opentui/core"
|
||||
import { For, type JSX } from "solid-js"
|
||||
import { useTheme, tint } from "@tui/context/theme"
|
||||
import { logo, marks } from "@/cli/logo"
|
||||
|
||||
// Shadow markers (rendered chars in parens):
|
||||
// _ = full shadow cell (space with bg=shadow)
|
||||
// ^ = letter top, shadow bottom (▀ with fg=letter, bg=shadow)
|
||||
// ~ = shadow top only (▀ with fg=shadow)
|
||||
const SHADOW_MARKER = /[_^~]/
|
||||
|
||||
const LOGO_LEFT = [` `, `█▀▀█ █▀▀█ █▀▀█ █▀▀▄`, `█__█ █__█ █^^^ █__█`, `▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀~~▀`]
|
||||
|
||||
const LOGO_RIGHT = [` ▄ `, `█▀▀▀ █▀▀█ █▀▀█ █▀▀█`, `█___ █__█ █__█ █^^^`, `▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`]
|
||||
const SHADOW_MARKER = new RegExp(`[${marks}]`)
|
||||
|
||||
export function Logo() {
|
||||
const { theme } = useTheme()
|
||||
@@ -75,11 +72,11 @@ export function Logo() {
|
||||
|
||||
return (
|
||||
<box>
|
||||
<For each={LOGO_LEFT}>
|
||||
<For each={logo.left}>
|
||||
{(line, index) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box flexDirection="row">{renderLine(line, theme.textMuted, false)}</box>
|
||||
<box flexDirection="row">{renderLine(LOGO_RIGHT[index()], theme.text, true)}</box>
|
||||
<box flexDirection="row">{renderLine(logo.right[index()], theme.text, true)}</box>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -58,6 +58,7 @@ import { DialogTimeline } from "./dialog-timeline"
|
||||
import { DialogForkFromTimeline } from "./dialog-fork-from-timeline"
|
||||
import { DialogSessionRename } from "../../component/dialog-session-rename"
|
||||
import { Sidebar } from "./sidebar"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
||||
import parsers from "../../../../../../parsers-config.ts"
|
||||
import { Clipboard } from "../../util/clipboard"
|
||||
@@ -1338,15 +1339,27 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
|
||||
return (
|
||||
<Show when={props.part.text.trim()}>
|
||||
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
|
||||
<code
|
||||
filetype="markdown"
|
||||
drawUnstyledText={false}
|
||||
streaming={true}
|
||||
syntaxStyle={syntax()}
|
||||
content={props.part.text.trim()}
|
||||
conceal={ctx.conceal()}
|
||||
fg={theme.text}
|
||||
/>
|
||||
<Switch>
|
||||
<Match when={Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
|
||||
<markdown
|
||||
syntaxStyle={syntax()}
|
||||
streaming={true}
|
||||
content={props.part.text.trim()}
|
||||
conceal={ctx.conceal()}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={!Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
|
||||
<code
|
||||
filetype="markdown"
|
||||
drawUnstyledText={false}
|
||||
streaming={true}
|
||||
syntaxStyle={syntax()}
|
||||
content={props.part.text.trim()}
|
||||
conceal={ctx.conceal()}
|
||||
fg={theme.text}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export const logo = {
|
||||
left: [" ", "█▀▀█ █▀▀█ █▀▀█ █▀▀▄", "█__█ █__█ █^^^ █__█", "▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀~~▀"],
|
||||
right: [" ▄ ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀█", "█___ █__█ █__█ █^^^", "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"],
|
||||
}
|
||||
|
||||
export const marks = "_^~"
|
||||
@@ -1,15 +1,9 @@
|
||||
import z from "zod"
|
||||
import { EOL } from "os"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { logo as glyphs } from "./logo"
|
||||
|
||||
export namespace UI {
|
||||
const LOGO = [
|
||||
[` `, ` ▄ `],
|
||||
[`█▀▀█ █▀▀█ █▀▀█ █▀▀▄ `, `█▀▀▀ █▀▀█ █▀▀█ █▀▀█`],
|
||||
[`█░░█ █░░█ █▀▀▀ █░░█ `, `█░░░ █░░█ █░░█ █▀▀▀`],
|
||||
[`▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ `, `▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`],
|
||||
]
|
||||
|
||||
export const CancelledError = NamedError.create("UICancelledError", z.void())
|
||||
|
||||
export const Style = {
|
||||
@@ -47,15 +41,50 @@ export namespace UI {
|
||||
}
|
||||
|
||||
export function logo(pad?: string) {
|
||||
const result = []
|
||||
for (const row of LOGO) {
|
||||
if (pad) result.push(pad)
|
||||
result.push(Bun.color("gray", "ansi"))
|
||||
result.push(row[0])
|
||||
result.push("\x1b[0m")
|
||||
result.push(row[1])
|
||||
result.push(EOL)
|
||||
const result: string[] = []
|
||||
const reset = "\x1b[0m"
|
||||
const left = {
|
||||
fg: Bun.color("gray", "ansi") ?? "",
|
||||
shadow: "\x1b[38;5;235m",
|
||||
bg: "\x1b[48;5;235m",
|
||||
}
|
||||
const right = {
|
||||
fg: reset,
|
||||
shadow: "\x1b[38;5;238m",
|
||||
bg: "\x1b[48;5;238m",
|
||||
}
|
||||
const gap = " "
|
||||
const draw = (line: string, fg: string, shadow: string, bg: string) => {
|
||||
const parts: string[] = []
|
||||
for (const char of line) {
|
||||
if (char === "_") {
|
||||
parts.push(bg, " ", reset)
|
||||
continue
|
||||
}
|
||||
if (char === "^") {
|
||||
parts.push(fg, bg, "▀", reset)
|
||||
continue
|
||||
}
|
||||
if (char === "~") {
|
||||
parts.push(shadow, "▀", reset)
|
||||
continue
|
||||
}
|
||||
if (char === " ") {
|
||||
parts.push(" ")
|
||||
continue
|
||||
}
|
||||
parts.push(fg, char, reset)
|
||||
}
|
||||
return parts.join("")
|
||||
}
|
||||
glyphs.left.forEach((row, index) => {
|
||||
if (pad) result.push(pad)
|
||||
result.push(draw(row, left.fg, left.shadow, left.bg))
|
||||
result.push(gap)
|
||||
const other = glyphs.right[index] ?? ""
|
||||
result.push(draw(other, right.fg, right.shadow, right.bg))
|
||||
result.push(EOL)
|
||||
})
|
||||
return result.join("").trimEnd()
|
||||
}
|
||||
|
||||
|
||||
@@ -1104,20 +1104,23 @@ export namespace Config {
|
||||
mergeDeep(await loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
|
||||
)
|
||||
|
||||
await import(path.join(Global.Path.config, "config"), {
|
||||
with: {
|
||||
type: "toml",
|
||||
},
|
||||
})
|
||||
.then(async (mod) => {
|
||||
const { provider, model, ...rest } = mod.default
|
||||
if (provider && model) result.model = `${provider}/${model}`
|
||||
result["$schema"] = "https://opencode.ai/config.json"
|
||||
result = mergeDeep(result, rest)
|
||||
await Bun.write(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
|
||||
await fs.unlink(path.join(Global.Path.config, "config"))
|
||||
const legacy = path.join(Global.Path.config, "config")
|
||||
if (existsSync(legacy)) {
|
||||
await import(pathToFileURL(legacy).href, {
|
||||
with: {
|
||||
type: "toml",
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(async (mod) => {
|
||||
const { provider, model, ...rest } = mod.default
|
||||
if (provider && model) result.model = `${provider}/${model}`
|
||||
result["$schema"] = "https://opencode.ai/config.json"
|
||||
result = mergeDeep(result, rest)
|
||||
await Bun.write(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
|
||||
await fs.unlink(legacy)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
@@ -1341,24 +1344,35 @@ export namespace Config {
|
||||
throw new JsonError({ path: filepath }, { cause: err })
|
||||
})
|
||||
|
||||
if (!filepath.endsWith(".jsonc")) {
|
||||
const existing = parseConfig(before, filepath)
|
||||
await Bun.write(filepath, JSON.stringify(mergeDeep(existing, config), null, 2))
|
||||
} else {
|
||||
const next = patchJsonc(before, config)
|
||||
parseConfig(next, filepath)
|
||||
await Bun.write(filepath, next)
|
||||
}
|
||||
const next = await (async () => {
|
||||
if (!filepath.endsWith(".jsonc")) {
|
||||
const existing = parseConfig(before, filepath)
|
||||
const merged = mergeDeep(existing, config)
|
||||
await Bun.write(filepath, JSON.stringify(merged, null, 2))
|
||||
return merged
|
||||
}
|
||||
|
||||
const updated = patchJsonc(before, config)
|
||||
const merged = parseConfig(updated, filepath)
|
||||
await Bun.write(filepath, updated)
|
||||
return merged
|
||||
})()
|
||||
|
||||
global.reset()
|
||||
await Instance.disposeAll()
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
payload: {
|
||||
type: Event.Disposed.type,
|
||||
properties: {},
|
||||
},
|
||||
})
|
||||
|
||||
void Instance.disposeAll()
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
payload: {
|
||||
type: Event.Disposed.type,
|
||||
properties: {},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
export async function directories() {
|
||||
|
||||
@@ -46,6 +46,7 @@ export namespace Flag {
|
||||
export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
|
||||
export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
|
||||
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
|
||||
export const OPENCODE_EXPERIMENTAL_MARKDOWN = truthy("OPENCODE_EXPERIMENTAL_MARKDOWN")
|
||||
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
|
||||
|
||||
function number(key: string) {
|
||||
|
||||
@@ -33,7 +33,7 @@ await Promise.all([
|
||||
fs.mkdir(Global.Path.bin, { recursive: true }),
|
||||
])
|
||||
|
||||
const CACHE_VERSION = "19"
|
||||
const CACHE_VERSION = "21"
|
||||
|
||||
const version = await Bun.file(path.join(Global.Path.cache, "version"))
|
||||
.text()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import z from "zod"
|
||||
import { Global } from "../global"
|
||||
|
||||
@@ -65,16 +64,14 @@ export namespace McpAuth {
|
||||
if (serverUrl) {
|
||||
entry.serverUrl = serverUrl
|
||||
}
|
||||
await Bun.write(file, JSON.stringify({ ...data, [mcpName]: entry }, null, 2))
|
||||
await fs.chmod(file.name!, 0o600)
|
||||
await Bun.write(file, JSON.stringify({ ...data, [mcpName]: entry }, null, 2), { mode: 0o600 })
|
||||
}
|
||||
|
||||
export async function remove(mcpName: string): Promise<void> {
|
||||
const file = Bun.file(filepath)
|
||||
const data = await all()
|
||||
delete data[mcpName]
|
||||
await Bun.write(file, JSON.stringify(data, null, 2))
|
||||
await fs.chmod(file.name!, 0o600)
|
||||
await Bun.write(file, JSON.stringify(data, null, 2), { mode: 0o600 })
|
||||
}
|
||||
|
||||
export async function updateTokens(mcpName: string, tokens: Tokens, serverUrl?: string): Promise<void> {
|
||||
|
||||
@@ -10,6 +10,7 @@ const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
const ISSUER = "https://auth.openai.com"
|
||||
const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
|
||||
const OAUTH_PORT = 1455
|
||||
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000
|
||||
|
||||
interface PkceCodes {
|
||||
verifier: string
|
||||
@@ -461,7 +462,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
},
|
||||
methods: [
|
||||
{
|
||||
label: "ChatGPT Pro/Plus",
|
||||
label: "ChatGPT Pro/Plus (browser)",
|
||||
type: "oauth",
|
||||
authorize: async () => {
|
||||
const { redirectUri } = await startOAuthServer()
|
||||
@@ -490,6 +491,89 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "ChatGPT Pro/Plus (headless)",
|
||||
type: "oauth",
|
||||
authorize: async () => {
|
||||
const deviceResponse = await fetch(`${ISSUER}/api/accounts/deviceauth/usercode`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": `opencode/${Installation.VERSION}`,
|
||||
},
|
||||
body: JSON.stringify({ client_id: CLIENT_ID }),
|
||||
})
|
||||
|
||||
if (!deviceResponse.ok) throw new Error("Failed to initiate device authorization")
|
||||
|
||||
const deviceData = (await deviceResponse.json()) as {
|
||||
device_auth_id: string
|
||||
user_code: string
|
||||
interval: string
|
||||
}
|
||||
const interval = Math.max(parseInt(deviceData.interval) || 5, 1) * 1000
|
||||
|
||||
return {
|
||||
url: `${ISSUER}/codex/device`,
|
||||
instructions: `Enter code: ${deviceData.user_code}`,
|
||||
method: "auto" as const,
|
||||
async callback() {
|
||||
while (true) {
|
||||
const response = await fetch(`${ISSUER}/api/accounts/deviceauth/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": `opencode/${Installation.VERSION}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
device_auth_id: deviceData.device_auth_id,
|
||||
user_code: deviceData.user_code,
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as {
|
||||
authorization_code: string
|
||||
code_verifier: string
|
||||
}
|
||||
|
||||
const tokenResponse = await fetch(`${ISSUER}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: data.authorization_code,
|
||||
redirect_uri: `${ISSUER}/deviceauth/callback`,
|
||||
client_id: CLIENT_ID,
|
||||
code_verifier: data.code_verifier,
|
||||
}).toString(),
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
throw new Error(`Token exchange failed: ${tokenResponse.status}`)
|
||||
}
|
||||
|
||||
const tokens: TokenResponse = await tokenResponse.json()
|
||||
|
||||
return {
|
||||
type: "success" as const,
|
||||
refresh: tokens.refresh_token,
|
||||
access: tokens.access_token,
|
||||
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
||||
accountId: extractAccountId(tokens),
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status !== 403 && response.status !== 404) {
|
||||
return { type: "failed" as const }
|
||||
}
|
||||
|
||||
await Bun.sleep(interval + OAUTH_POLLING_SAFETY_MARGIN_MS)
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Manually enter API Key",
|
||||
type: "api",
|
||||
|
||||
@@ -61,12 +61,13 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
const info = await getAuth()
|
||||
if (info.type !== "oauth") return fetch(request, init)
|
||||
|
||||
const url = request instanceof URL ? request.href : request.toString()
|
||||
const { isVision, isAgent } = iife(() => {
|
||||
try {
|
||||
const body = typeof init?.body === "string" ? JSON.parse(init.body) : init?.body
|
||||
|
||||
// Completions API
|
||||
if (body?.messages) {
|
||||
if (body?.messages && url.includes("completions")) {
|
||||
const last = body.messages[body.messages.length - 1]
|
||||
return {
|
||||
isVision: body.messages.some(
|
||||
@@ -88,6 +89,28 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
isAgent: last?.role !== "user",
|
||||
}
|
||||
}
|
||||
|
||||
// Messages API
|
||||
if (body?.messages) {
|
||||
const last = body.messages[body.messages.length - 1]
|
||||
const hasNonToolCalls =
|
||||
Array.isArray(last?.content) && last.content.some((part: any) => part?.type !== "tool_result")
|
||||
return {
|
||||
isVision: body.messages.some(
|
||||
(item: any) =>
|
||||
Array.isArray(item?.content) &&
|
||||
item.content.some(
|
||||
(part: any) =>
|
||||
part?.type === "image" ||
|
||||
// images can be nested inside tool_result content
|
||||
(part?.type === "tool_result" &&
|
||||
Array.isArray(part?.content) &&
|
||||
part.content.some((nested: any) => nested?.type === "image")),
|
||||
),
|
||||
),
|
||||
isAgent: !(last?.role === "user" && hasNonToolCalls),
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return { isVision: false, isAgent: false }
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ import { CopilotAuthPlugin } from "./copilot"
|
||||
export namespace Plugin {
|
||||
const log = Log.create({ service: "plugin" })
|
||||
|
||||
const BUILTIN = ["opencode-anthropic-auth@0.0.10", "@gitlab/opencode-gitlab-auth@1.3.2"]
|
||||
const BUILTIN = ["opencode-anthropic-auth@0.0.13", "@gitlab/opencode-gitlab-auth@1.3.2"]
|
||||
|
||||
// Built-in plugins that are directly imported (not installed from npm)
|
||||
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin]
|
||||
|
||||
@@ -14,6 +14,10 @@ interface Context {
|
||||
const context = Context.create<Context>("instance")
|
||||
const cache = new Map<string, Promise<Context>>()
|
||||
|
||||
const disposal = {
|
||||
all: undefined as Promise<void> | undefined,
|
||||
}
|
||||
|
||||
export const Instance = {
|
||||
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
|
||||
let existing = cache.get(input.directory)
|
||||
@@ -77,15 +81,34 @@ export const Instance = {
|
||||
})
|
||||
},
|
||||
async disposeAll() {
|
||||
Log.Default.info("disposing all instances")
|
||||
for (const [_key, value] of cache) {
|
||||
const awaited = await value.catch(() => {})
|
||||
if (awaited) {
|
||||
await context.provide(await value, async () => {
|
||||
if (disposal.all) return disposal.all
|
||||
|
||||
disposal.all = iife(async () => {
|
||||
Log.Default.info("disposing all instances")
|
||||
const entries = [...cache.entries()]
|
||||
for (const [key, value] of entries) {
|
||||
if (cache.get(key) !== value) continue
|
||||
|
||||
const ctx = await value.catch((error) => {
|
||||
Log.Default.warn("instance dispose failed", { key, error })
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!ctx) {
|
||||
if (cache.get(key) === value) cache.delete(key)
|
||||
continue
|
||||
}
|
||||
|
||||
if (cache.get(key) !== value) continue
|
||||
|
||||
await context.provide(ctx, async () => {
|
||||
await Instance.dispose()
|
||||
})
|
||||
}
|
||||
}
|
||||
cache.clear()
|
||||
}).finally(() => {
|
||||
disposal.all = undefined
|
||||
})
|
||||
|
||||
return disposal.all
|
||||
},
|
||||
}
|
||||
|
||||
@@ -46,20 +46,24 @@ export namespace State {
|
||||
}, 10000).unref()
|
||||
|
||||
const tasks: Promise<void>[] = []
|
||||
for (const entry of entries.values()) {
|
||||
for (const [init, entry] of entries) {
|
||||
if (!entry.dispose) continue
|
||||
|
||||
const label = typeof init === "function" ? init.name : String(init)
|
||||
|
||||
const task = Promise.resolve(entry.state)
|
||||
.then((state) => entry.dispose!(state))
|
||||
.catch((error) => {
|
||||
log.error("Error while disposing state:", { error, key })
|
||||
log.error("Error while disposing state:", { error, key, init: label })
|
||||
})
|
||||
|
||||
tasks.push(task)
|
||||
}
|
||||
await Promise.all(tasks)
|
||||
|
||||
entries.clear()
|
||||
recordsByKey.delete(key)
|
||||
await Promise.all(tasks)
|
||||
|
||||
disposalFinished = true
|
||||
log.info("state disposal completed", { key })
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, resolver } from "hono-openapi"
|
||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||
import { streamSSE } from "hono/streaming"
|
||||
import z from "zod"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
@@ -8,6 +8,8 @@ import { Instance } from "../../project/instance"
|
||||
import { Installation } from "@/installation"
|
||||
import { Log } from "../../util/log"
|
||||
import { lazy } from "../../util/lazy"
|
||||
import { Config } from "../../config/config"
|
||||
import { errors } from "../error"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
@@ -103,6 +105,52 @@ export const GlobalRoutes = lazy(() =>
|
||||
})
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/config",
|
||||
describeRoute({
|
||||
summary: "Get global configuration",
|
||||
description: "Retrieve the current global OpenCode configuration settings and preferences.",
|
||||
operationId: "global.config.get",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Get global config info",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Config.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await Config.getGlobal())
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/config",
|
||||
describeRoute({
|
||||
summary: "Update global configuration",
|
||||
description: "Update global OpenCode configuration settings and preferences.",
|
||||
operationId: "global.config.update",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully updated global config",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Config.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Config.Info),
|
||||
async (c) => {
|
||||
const config = c.req.valid("json")
|
||||
const next = await Config.updateGlobal(config)
|
||||
return c.json(next)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/dispose",
|
||||
describeRoute({
|
||||
|
||||
@@ -122,6 +122,68 @@ export namespace Server {
|
||||
}),
|
||||
)
|
||||
.route("/global", GlobalRoutes())
|
||||
.put(
|
||||
"/auth/:providerID",
|
||||
describeRoute({
|
||||
summary: "Set auth credentials",
|
||||
description: "Set authentication credentials",
|
||||
operationId: "auth.set",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully set authentication credentials",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
}),
|
||||
),
|
||||
validator("json", Auth.Info),
|
||||
async (c) => {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
const info = c.req.valid("json")
|
||||
await Auth.set(providerID, info)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/auth/:providerID",
|
||||
describeRoute({
|
||||
summary: "Remove auth credentials",
|
||||
description: "Remove authentication credentials",
|
||||
operationId: "auth.remove",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully removed authentication credentials",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
await Auth.remove(providerID)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.use(async (c, next) => {
|
||||
let directory = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
|
||||
try {
|
||||
@@ -409,68 +471,6 @@ export namespace Server {
|
||||
return c.json(await Format.status())
|
||||
},
|
||||
)
|
||||
.put(
|
||||
"/auth/:providerID",
|
||||
describeRoute({
|
||||
summary: "Set auth credentials",
|
||||
description: "Set authentication credentials",
|
||||
operationId: "auth.set",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully set authentication credentials",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
}),
|
||||
),
|
||||
validator("json", Auth.Info),
|
||||
async (c) => {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
const info = c.req.valid("json")
|
||||
await Auth.set(providerID, info)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/auth/:providerID",
|
||||
describeRoute({
|
||||
summary: "Remove auth credentials",
|
||||
description: "Remove authentication credentials",
|
||||
operationId: "auth.remove",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully removed authentication credentials",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
await Auth.remove(providerID)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/event",
|
||||
describeRoute({
|
||||
|
||||
@@ -41,6 +41,32 @@ async function resolveRelative(instruction: string): Promise<string[]> {
|
||||
}
|
||||
|
||||
export namespace InstructionPrompt {
|
||||
const state = Instance.state(() => {
|
||||
return {
|
||||
claims: new Map<string, Set<string>>(),
|
||||
}
|
||||
})
|
||||
|
||||
function isClaimed(messageID: string, filepath: string) {
|
||||
const claimed = state().claims.get(messageID)
|
||||
if (!claimed) return false
|
||||
return claimed.has(filepath)
|
||||
}
|
||||
|
||||
function claim(messageID: string, filepath: string) {
|
||||
const current = state()
|
||||
let claimed = current.claims.get(messageID)
|
||||
if (!claimed) {
|
||||
claimed = new Set()
|
||||
current.claims.set(messageID, claimed)
|
||||
}
|
||||
claimed.add(filepath)
|
||||
}
|
||||
|
||||
export function clear(messageID: string) {
|
||||
state().claims.delete(messageID)
|
||||
}
|
||||
|
||||
export async function systemPaths() {
|
||||
const config = await Config.get()
|
||||
const paths = new Set<string>()
|
||||
@@ -137,7 +163,7 @@ export namespace InstructionPrompt {
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolve(messages: MessageV2.WithParts[], filepath: string) {
|
||||
export async function resolve(messages: MessageV2.WithParts[], filepath: string, messageID: string) {
|
||||
const system = await systemPaths()
|
||||
const already = loaded(messages)
|
||||
const results: { filepath: string; content: string }[] = []
|
||||
@@ -147,7 +173,8 @@ export namespace InstructionPrompt {
|
||||
|
||||
while (current.startsWith(root)) {
|
||||
const found = await find(current)
|
||||
if (found && !system.has(found) && !already.has(found)) {
|
||||
if (found && !system.has(found) && !already.has(found) && !isClaimed(messageID, found)) {
|
||||
claim(messageID, found)
|
||||
const content = await Bun.file(found)
|
||||
.text()
|
||||
.catch(() => undefined)
|
||||
|
||||
@@ -177,6 +177,8 @@ export namespace MessageV2 {
|
||||
})
|
||||
.optional(),
|
||||
command: z.string().optional(),
|
||||
}).meta({
|
||||
ref: "SubtaskPart",
|
||||
})
|
||||
export type SubtaskPart = z.infer<typeof SubtaskPart>
|
||||
|
||||
|
||||
@@ -549,6 +549,7 @@ export namespace SessionPrompt {
|
||||
model,
|
||||
abort,
|
||||
})
|
||||
using _ = defer(() => InstructionPrompt.clear(processor.message.id))
|
||||
|
||||
// Check if user explicitly invoked an agent via @ in this turn
|
||||
const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
|
||||
@@ -837,6 +838,7 @@ export namespace SessionPrompt {
|
||||
system: input.system,
|
||||
variant: input.variant,
|
||||
}
|
||||
using _ = defer(() => InstructionPrompt.clear(info.id))
|
||||
|
||||
const parts = await Promise.all(
|
||||
input.parts.map(async (part): Promise<MessageV2.Part[]> => {
|
||||
|
||||
@@ -60,7 +60,7 @@ export const ReadTool = Tool.define("read", {
|
||||
throw new Error(`File not found: ${filepath}`)
|
||||
}
|
||||
|
||||
const instructions = await InstructionPrompt.resolve(ctx.messages, filepath)
|
||||
const instructions = await InstructionPrompt.resolve(ctx.messages, filepath, ctx.messageID)
|
||||
|
||||
// Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files)
|
||||
const isImage =
|
||||
|
||||
@@ -159,8 +159,10 @@ export const TaskTool = Tool.define("task", async (ctx) => {
|
||||
...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])),
|
||||
},
|
||||
parts: promptParts,
|
||||
}).finally(() => {
|
||||
unsub()
|
||||
})
|
||||
unsub()
|
||||
|
||||
const messages = await Session.messages({ sessionID: session.id })
|
||||
const summary = messages
|
||||
.filter((x) => x.info.role === "assistant")
|
||||
|
||||
@@ -18,7 +18,7 @@ describe("InstructionPrompt.resolve", () => {
|
||||
const system = await InstructionPrompt.systemPaths()
|
||||
expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true)
|
||||
|
||||
const results = await InstructionPrompt.resolve([], path.join(tmp.path, "src", "file.ts"))
|
||||
const results = await InstructionPrompt.resolve([], path.join(tmp.path, "src", "file.ts"), "test-message-1")
|
||||
expect(results).toEqual([])
|
||||
},
|
||||
})
|
||||
@@ -37,7 +37,11 @@ describe("InstructionPrompt.resolve", () => {
|
||||
const system = await InstructionPrompt.systemPaths()
|
||||
expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false)
|
||||
|
||||
const results = await InstructionPrompt.resolve([], path.join(tmp.path, "subdir", "nested", "file.ts"))
|
||||
const results = await InstructionPrompt.resolve(
|
||||
[],
|
||||
path.join(tmp.path, "subdir", "nested", "file.ts"),
|
||||
"test-message-2",
|
||||
)
|
||||
expect(results.length).toBe(1)
|
||||
expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user