This commit is contained in:
Dax Raad
2025-08-06 19:20:40 -04:00
parent 15e830410f
commit 390b660c81
14 changed files with 584 additions and 193 deletions

View File

@@ -1,10 +1,11 @@
import { App } from "../app/app"
import { Config } from "../config/config"
import z from "zod"
import { Provider } from "../provider/provider"
import { generateObject, type ModelMessage } from "ai"
import PROMPT_GENERATE from "./generate.txt"
import { SystemPrompt } from "../session/system"
import { State } from "../project/state"
import { Session } from "../session"
export namespace Agent {
export const Info = z
@@ -24,46 +25,49 @@ export namespace Agent {
ref: "Agent",
})
export type Info = z.infer<typeof Info>
const state = App.state("agent", async () => {
const cfg = await Config.get()
const result: Record<string, Info> = {
general: {
name: "general",
description:
"General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.",
tools: {
todoread: false,
todowrite: false,
},
},
}
for (const [key, value] of Object.entries(cfg.agent ?? {})) {
if (value.disable) {
delete result[key]
continue
}
let item = result[key]
if (!item)
item = result[key] = {
name: key,
description: "",
const state = State.create(
() => Session.use().directory,
async () => {
const cfg = await Config.get()
const result: Record<string, Info> = {
general: {
name: "general",
description:
"General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.",
tools: {
todowrite: false,
todoread: false,
todowrite: false,
},
},
}
for (const [key, value] of Object.entries(cfg.agent ?? {})) {
if (value.disable) {
delete result[key]
continue
}
const model = value.model ?? cfg.model
if (model) item.model = Provider.parseModel(model)
if (value.prompt) item.prompt = value.prompt
if (value.tools)
item.tools = {
...item.tools,
...value.tools,
}
if (value.description) item.description = value.description
}
return result
})
let item = result[key]
if (!item)
item = result[key] = {
name: key,
description: "",
tools: {
todowrite: false,
todoread: false,
},
}
const model = value.model ?? cfg.model
if (model) item.model = Provider.parseModel(model)
if (value.prompt) item.prompt = value.prompt
if (value.tools)
item.tools = {
...item.tools,
...value.tools,
}
if (value.description) item.description = value.description
}
return result
},
)
export async function get(agent: string) {
return state().then((x) => x[agent])

View File

@@ -6,6 +6,8 @@ import { Global } from "../global"
import path from "path"
import os from "os"
import { z } from "zod"
import { Project } from "../project/project"
import { Worktree } from "../project/worktree"
export namespace App {
const log = Log.create({ service: "app" })
@@ -87,17 +89,30 @@ export namespace App {
info,
}
const projects = await Project.list()
const project = projects
.toSorted((a, b) => a.worktree.length - b.worktree.length)
.find((x) => Filesystem.contains(x.worktree, input.cwd))
if (!project) {
throw new Error("No project found")
}
return ctx.provide(app, async () => {
try {
const result = await cb(app.info)
return result
} finally {
for (const [key, entry] of app.services.entries()) {
if (!entry.shutdown) continue
log.info("shutdown", { name: key })
await entry.shutdown?.(await entry.state)
}
}
return Project.provide(project, async () => {
return Worktree.provide(project.worktree, async () => {
try {
const result = await cb(app.info)
return result
} finally {
for (const [key, entry] of app.services.entries()) {
if (!entry.shutdown) continue
log.info("shutdown", { name: key })
await entry.shutdown?.(await entry.state)
}
}
})
})
})
}

View File

@@ -37,6 +37,10 @@ export namespace Bus {
.object({
type: z.literal(type),
properties: def.properties,
context: z.object({
projectID: z.string().optional(),
sessionID: z.string().optional(),
}),
})
.openapi({
ref: "Event" + "." + def.type,

View File

@@ -1,7 +1,14 @@
import { Project } from "../../../project/project"
import { Log } from "../../../util/log"
import { cmd } from "../cmd"
export const ScrapCommand = cmd({
command: "scrap",
builder: (yargs) => yargs,
async handler() {},
async handler() {
const timer = Log.Default.time("scrap")
const list = await Project.list()
console.log(list)
timer.stop()
},
})

View File

@@ -2,7 +2,6 @@ import { Log } from "../util/log"
import path from "path"
import os from "os"
import { z } from "zod"
import { App } from "../app/app"
import { Filesystem } from "../util/filesystem"
import { ModelsDev } from "../provider/models"
import { mergeDeep, pipe } from "remeda"
@@ -14,110 +13,115 @@ import matter from "gray-matter"
import { Flag } from "../flag/flag"
import { Auth } from "../auth"
import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
import { State } from "../project/state"
import { Project } from "../project/project"
export namespace Config {
const log = Log.create({ service: "config" })
export const state = App.state("config", async (app) => {
const auth = await Auth.all()
let result = await global()
for (const file of ["opencode.jsonc", "opencode.json"]) {
const found = await Filesystem.findUp(file, app.path.cwd, app.path.root)
for (const resolved of found.toReversed()) {
result = mergeDeep(result, await loadFile(resolved))
export const state = State.create(
() => Project.use().worktree,
async () => {
const auth = await Auth.all()
let result = await global()
for (const file of ["opencode.jsonc", "opencode.json"]) {
const found = await Filesystem.findUp(file, app.path.cwd, app.path.root)
for (const resolved of found.toReversed()) {
result = mergeDeep(result, await loadFile(resolved))
}
}
}
// Override with custom config if provided
if (Flag.OPENCODE_CONFIG) {
result = mergeDeep(result, await loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
}
for (const [key, value] of Object.entries(auth)) {
if (value.type === "wellknown") {
process.env[value.key] = value.token
const wellknown = await fetch(`${key}/.well-known/opencode`).then((x) => x.json())
result = mergeDeep(result, await load(JSON.stringify(wellknown.config ?? {}), process.cwd()))
// Override with custom config if provided
if (Flag.OPENCODE_CONFIG) {
result = mergeDeep(result, await loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
}
}
result.agent = result.agent || {}
const markdownAgents = [
...(await Filesystem.globUp("agent/*.md", Global.Path.config, Global.Path.config)),
...(await Filesystem.globUp(".opencode/agent/*.md", app.path.cwd, app.path.root)),
]
for (const item of markdownAgents) {
const content = await Bun.file(item).text()
const md = matter(content)
if (!md.data) continue
const config = {
name: path.basename(item, ".md"),
...md.data,
prompt: md.content.trim(),
for (const [key, value] of Object.entries(auth)) {
if (value.type === "wellknown") {
process.env[value.key] = value.token
const wellknown = await fetch(`${key}/.well-known/opencode`).then((x) => x.json())
result = mergeDeep(result, await load(JSON.stringify(wellknown.config ?? {}), process.cwd()))
}
}
const parsed = Agent.safeParse(config)
if (parsed.success) {
result.agent = mergeDeep(result.agent, {
[config.name]: parsed.data,
})
continue
result.agent = result.agent || {}
const markdownAgents = [
...(await Filesystem.globUp("agent/*.md", Global.Path.config, Global.Path.config)),
...(await Filesystem.globUp(".opencode/agent/*.md", app.path.cwd, app.path.root)),
]
for (const item of markdownAgents) {
const content = await Bun.file(item).text()
const md = matter(content)
if (!md.data) continue
const config = {
name: path.basename(item, ".md"),
...md.data,
prompt: md.content.trim(),
}
const parsed = Agent.safeParse(config)
if (parsed.success) {
result.agent = mergeDeep(result.agent, {
[config.name]: parsed.data,
})
continue
}
throw new InvalidError({ path: item }, { cause: parsed.error })
}
throw new InvalidError({ path: item }, { cause: parsed.error })
}
// Load mode markdown files
result.mode = result.mode || {}
const markdownModes = [
...(await Filesystem.globUp("mode/*.md", Global.Path.config, Global.Path.config)),
...(await Filesystem.globUp(".opencode/mode/*.md", app.path.cwd, app.path.root)),
]
for (const item of markdownModes) {
const content = await Bun.file(item).text()
const md = matter(content)
if (!md.data) continue
// Load mode markdown files
result.mode = result.mode || {}
const markdownModes = [
...(await Filesystem.globUp("mode/*.md", Global.Path.config, Global.Path.config)),
...(await Filesystem.globUp(".opencode/mode/*.md", app.path.cwd, app.path.root)),
]
for (const item of markdownModes) {
const content = await Bun.file(item).text()
const md = matter(content)
if (!md.data) continue
const config = {
name: path.basename(item, ".md"),
...md.data,
prompt: md.content.trim(),
const config = {
name: path.basename(item, ".md"),
...md.data,
prompt: md.content.trim(),
}
const parsed = Mode.safeParse(config)
if (parsed.success) {
result.mode = mergeDeep(result.mode, {
[config.name]: parsed.data,
})
continue
}
throw new InvalidError({ path: item }, { cause: parsed.error })
}
const parsed = Mode.safeParse(config)
if (parsed.success) {
result.mode = mergeDeep(result.mode, {
[config.name]: parsed.data,
})
continue
result.plugin = result.plugin || []
result.plugin.push(
...[
...(await Filesystem.globUp("plugin/*.ts", Global.Path.config, Global.Path.config)),
...(await Filesystem.globUp(".opencode/plugin/*.ts", app.path.cwd, app.path.root)),
].map((x) => "file://" + x),
)
// Handle migration from autoshare to share field
if (result.autoshare === true && !result.share) {
result.share = "auto"
}
if (result.keybinds?.messages_revert && !result.keybinds.messages_undo) {
result.keybinds.messages_undo = result.keybinds.messages_revert
}
throw new InvalidError({ path: item }, { cause: parsed.error })
}
result.plugin = result.plugin || []
result.plugin.push(
...[
...(await Filesystem.globUp("plugin/*.ts", Global.Path.config, Global.Path.config)),
...(await Filesystem.globUp(".opencode/plugin/*.ts", app.path.cwd, app.path.root)),
].map((x) => "file://" + x),
)
if (!result.username) {
const os = await import("os")
result.username = os.userInfo().username
}
// Handle migration from autoshare to share field
if (result.autoshare === true && !result.share) {
result.share = "auto"
}
if (result.keybinds?.messages_revert && !result.keybinds.messages_undo) {
result.keybinds.messages_undo = result.keybinds.messages_revert
}
log.info("loaded", result)
if (!result.username) {
const os = await import("os")
result.username = os.userInfo().username
}
log.info("loaded", result)
return result
})
return result
},
)
export const McpLocal = z
.object({

View File

@@ -11,7 +11,6 @@ export namespace ConfigHooks {
export function init() {
log.info("init")
const app = App.info()
Bus.subscribe(File.Event.Edited, async (payload) => {
const cfg = await Config.get()

View File

@@ -6,6 +6,8 @@ import { LSPServer } from "./server"
import { z } from "zod"
import { Config } from "../config/config"
import { spawn } from "child_process"
import { State } from "../project/state"
import { Worktree } from "../project/worktree"
export namespace LSP {
const log = Log.create({ service: "lsp" })
@@ -53,8 +55,8 @@ export namespace LSP {
})
export type DocumentSymbol = z.infer<typeof DocumentSymbol>
const state = App.state(
"lsp",
const state = State.create(
() => Worktree.use(),
async () => {
const clients: LSPClient.Info[] = []
const servers: Record<string, LSPServer.Info> = LSPServer

View File

@@ -0,0 +1,67 @@
import z from "zod"
import { lazy } from "../util/lazy"
import { Filesystem } from "../util/filesystem"
import path from "path"
import { $ } from "bun"
import { StorageNext } from "../storage/storage-next"
import { Context } from "../util/context"
export namespace Project {
export const Info = z.object({
id: z.string(),
worktree: z.string(),
vcs: z.literal("git").optional(),
})
export type Info = z.infer<typeof Info>
const context = Context.create<Info>("project")
export const use = context.use
export const provide = context.provide
const init = lazy(async () => {
const cwd = process.cwd()
const matches = Filesystem.up({ targets: [".git"], start: cwd })
const git = await matches.next().then((x) => x.value)
await matches.return()
if (!git) {
await StorageNext.write(["project", "global"], {
id: "global",
worktree: "/",
})
return
}
let worktree = path.dirname(git)
const [id] = await $`git rev-list --max-parents=0 --all`
.quiet()
.nothrow()
.cwd(worktree)
.text()
.then((x) =>
x
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted(),
)
worktree = path.dirname(
await $`git rev-parse --path-format=absolute --git-common-dir`
.quiet()
.nothrow()
.cwd(worktree)
.text()
.then((x) => x.trim()),
)
await StorageNext.write<Info>(["project", id], {
id,
worktree,
vcs: "git",
})
})
export async function list() {
await init()
const keys = await StorageNext.list(["project"])
return await Promise.all(keys.map((x) => StorageNext.read<Info>(x)))
}
}

View File

@@ -0,0 +1,29 @@
export namespace State {
interface Entry {
state: any
dispose?: (state: any) => Promise<void>
}
const entries = new Map<string, Entry>()
export function create<S>(root: () => string, init: () => S, dispose?: (state: Awaited<S>) => Promise<void>) {
return () => {
const key = root()
const exists = entries.get(key)
if (exists) return exists.state as S
const state = init()
entries.set(key, {
state,
dispose,
})
return state
}
}
export async function dispose() {
for (const [key, entry] of entries.entries()) {
if (!entry.dispose) continue
await entry.dispose(await entry.state)
}
}
}

View File

@@ -0,0 +1,7 @@
import { Context } from "../util/context"
export namespace Worktree {
const context = Context.create<string>("worktree")
export const use = context.use
export const provide = context.provide
}

View File

@@ -1,4 +1,3 @@
import path from "path"
import { Decimal } from "decimal.js"
import { z, ZodSchema } from "zod"
import {
@@ -30,7 +29,7 @@ import { ProviderTransform } from "../provider/transform"
import type { ModelsDev } from "../provider/models"
import { Share } from "../share/share"
import { Snapshot } from "../snapshot"
import { Storage } from "../storage/storage"
import { StorageNext } from "../storage/storage-next"
import { Log } from "../util/log"
import { NamedError } from "../util/error"
import { SystemPrompt } from "./system"
@@ -42,10 +41,17 @@ import { ReadTool } from "../tool/read"
import { mergeDeep, pipe, splitWhen } from "remeda"
import { ToolRegistry } from "../tool/registry"
import { Plugin } from "../plugin"
import { Project } from "../project/project"
import { Context } from "../util/context"
export namespace Session {
const log = Log.create({ service: "session" })
const context = Context.create<Info>("session")
export const use = context.use
export const provide = context.provide
const OUTPUT_TOKEN_MAX = 32_000
const parentSessionTitlePrefix = "New session - "
@@ -62,6 +68,9 @@ export namespace Session {
export const Info = z
.object({
id: Identifier.schema("session"),
projectID: z.string(),
directory: z.string(),
worktree: z.string(),
parentID: Identifier.schema("session").optional(),
share: z
.object({
@@ -129,8 +138,6 @@ export namespace Session {
const state = App.state(
"session",
() => {
const sessions = new Map<string, Info>()
const messages = new Map<string, MessageV2.Info[]>()
const pending = new Map<string, AbortController>()
const autoCompacting = new Map<string, boolean>()
const queued = new Map<
@@ -145,8 +152,6 @@ export namespace Session {
>()
return {
sessions,
messages,
pending,
autoCompacting,
queued,
@@ -160,19 +165,30 @@ export namespace Session {
)
export async function create(parentID?: string) {
const result: Info = {
id: Identifier.descending("session"),
version: Installation.VERSION,
const app = App.info()
return createNext({
parentID,
title: createDefaultTitle(!!parentID),
directory: app.path.cwd,
})
}
export async function createNext(input: { id?: string; parentID?: string; directory: string }) {
const project = Project.use()
const result: Info = {
id: Identifier.descending("session", input.id),
version: Installation.VERSION,
projectID: project.id,
worktree: project.worktree,
directory: input.directory,
parentID: input.parentID,
title: createDefaultTitle(!!input.parentID),
time: {
created: Date.now(),
updated: Date.now(),
},
}
log.info("created", result)
state().sessions.set(result.id, result)
await Storage.writeJSON("session/info/" + result.id, result)
await StorageNext.write(["session", project.id, result.id], result)
const cfg = await Config.get()
if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto"))
share(result.id)
@@ -191,17 +207,13 @@ export namespace Session {
}
export async function get(id: string) {
const result = state().sessions.get(id)
if (result) {
return result
}
const read = await Storage.readJSON<Info>("session/info/" + id)
state().sessions.set(id, read)
const project = Project.use()
const read = await StorageNext.read<Info>(["session", project.id, id])
return read as Info
}
export async function getShare(id: string) {
return Storage.readJSON<ShareInfo>("session/share/" + id)
return StorageNext.read<ShareInfo>(["share", id])
}
export async function share(id: string) {
@@ -218,7 +230,7 @@ export namespace Session {
url: share.url,
}
})
await Storage.writeJSON<ShareInfo>("session/share/" + id, share)
await StorageNext.write(["share", id], share)
await Share.sync("session/info/" + id, session)
for (const msg of await messages(id)) {
await Share.sync("session/message/" + id + "/" + msg.info.id, msg.info)
@@ -232,7 +244,7 @@ export namespace Session {
export async function unshare(id: string) {
const share = await getShare(id)
if (!share) return
await Storage.remove("session/share/" + id)
await StorageNext.remove(["share", id])
await update(id, (draft) => {
draft.share = undefined
})
@@ -240,17 +252,15 @@ export namespace Session {
}
export async function update(id: string, editor: (session: Info) => void) {
const { sessions } = state()
const session = await get(id)
if (!session) return
editor(session)
session.time.updated = Date.now()
sessions.set(id, session)
await Storage.writeJSON("session/info/" + id, session)
Bus.publish(Event.Updated, {
info: session,
const project = Project.use()
const result = await StorageNext.update<Info>(["session", project.id, id], (draft) => {
editor(draft)
draft.time.updated = Date.now()
})
return session
Bus.publish(Event.Updated, {
info: result,
})
return result
}
export async function messages(sessionID: string) {
@@ -258,11 +268,11 @@ export namespace Session {
info: MessageV2.Info
parts: MessageV2.Part[]
}[]
for (const p of await Storage.list("session/message/" + sessionID)) {
const read = await Storage.readJSON<MessageV2.Info>(p)
for (const p of await StorageNext.list(["message", sessionID])) {
const read = await StorageNext.read<MessageV2.Info>(p)
result.push({
info: read,
parts: await getParts(sessionID, read.id),
parts: await getParts(read.id),
})
}
result.sort((a, b) => (a.info.id > b.info.id ? 1 : -1))
@@ -271,15 +281,15 @@ export namespace Session {
export async function getMessage(sessionID: string, messageID: string) {
return {
info: await Storage.readJSON<MessageV2.Info>("session/message/" + sessionID + "/" + messageID),
parts: await getParts(sessionID, messageID),
info: await StorageNext.read<MessageV2.Info>(["message", sessionID, messageID]),
parts: await getParts(messageID),
}
}
export async function getParts(sessionID: string, messageID: string) {
export async function getParts(messageID: string) {
const result = [] as MessageV2.Part[]
for (const item of await Storage.list("session/part/" + sessionID + "/" + messageID)) {
const read = await Storage.readJSON<MessageV2.Part>(item)
for (const item of await StorageNext.list(["part", messageID])) {
const read = await StorageNext.read<MessageV2.Part>(item)
result.push(read)
}
result.sort((a, b) => (a.id > b.id ? 1 : -1))
@@ -287,17 +297,17 @@ export namespace Session {
}
export async function* list() {
for (const item of await Storage.list("session/info")) {
const sessionID = path.basename(item, ".json")
yield get(sessionID)
const project = Project.use()
for (const item of await StorageNext.list(["session", project.id])) {
yield StorageNext.read<Info>(item)
}
}
export async function children(parentID: string) {
const project = Project.use()
const result = [] as Session.Info[]
for (const item of await Storage.list("session/info")) {
const sessionID = path.basename(item, ".json")
const session = await get(sessionID)
for (const item of await StorageNext.list(["session", project.id])) {
const session = await StorageNext.read<Info>(item)
if (session.parentID !== parentID) continue
result.push(session)
}
@@ -316,6 +326,7 @@ export namespace Session {
}
export async function remove(sessionID: string, emitEvent = true) {
const project = Project.use()
try {
abort(sessionID)
const session = await get(sessionID)
@@ -323,10 +334,13 @@ export namespace Session {
await remove(child.id, false)
}
await unshare(sessionID).catch(() => {})
await Storage.remove(`session/info/${sessionID}`).catch(() => {})
await Storage.removeDir(`session/message/${sessionID}/`).catch(() => {})
state().sessions.delete(sessionID)
state().messages.delete(sessionID)
for (const msg of await StorageNext.list(["message", sessionID])) {
for (const part of await StorageNext.list(["part", msg.at(-1)!])) {
await StorageNext.remove(part)
}
await StorageNext.remove(msg)
}
await StorageNext.remove(["session", project.id, sessionID])
if (emitEvent) {
Bus.publish(Event.Deleted, {
info: session,
@@ -338,14 +352,14 @@ export namespace Session {
}
async function updateMessage(msg: MessageV2.Info) {
await Storage.writeJSON("session/message/" + msg.sessionID + "/" + msg.id, msg)
await StorageNext.write(["message", msg.sessionID, msg.id], msg)
Bus.publish(MessageV2.Event.Updated, {
info: msg,
})
}
async function updatePart(part: MessageV2.Part) {
await Storage.writeJSON(["session", "part", part.sessionID, part.messageID, part.id].join("/"), part)
await StorageNext.write(["part", part.messageID, part.id], part)
Bus.publish(MessageV2.Event.PartUpdated, {
part,
})
@@ -403,7 +417,7 @@ export namespace Session {
const [preserve, remove] = splitWhen(msgs, (x) => x.info.id === messageID)
msgs = preserve
for (const msg of remove) {
await Storage.remove(`session/message/${input.sessionID}/${msg.info.id}`)
await StorageNext.remove(["message", input.sessionID, msg.info.id])
await Bus.publish(MessageV2.Event.Removed, { sessionID: input.sessionID, messageID: msg.info.id })
}
const last = preserve.at(-1)
@@ -412,7 +426,7 @@ export namespace Session {
const [preserveParts, removeParts] = splitWhen(last.parts, (x) => x.id === partID)
last.parts = preserveParts
for (const part of removeParts) {
await Storage.remove(`session/part/${input.sessionID}/${last.info.id}/${part.id}`)
await StorageNext.remove(["part", last.info.id, part.id])
await Bus.publish(MessageV2.Event.PartRemoved, {
sessionID: input.sessionID,
messageID: last.info.id,
@@ -1164,7 +1178,7 @@ export namespace Session {
error: assistantMsg.error,
})
}
const p = await getParts(assistantMsg.sessionID, assistantMsg.id)
const p = await getParts(assistantMsg.id)
for (const part of p) {
if (part.type === "tool" && part.state.status !== "completed" && part.state.status !== "error") {
updatePart({

View File

@@ -0,0 +1,78 @@
import { Log } from "../util/log"
import path from "path"
import fs from "fs/promises"
import { Global } from "../global"
import { lazy } from "../util/lazy"
import { Lock } from "../util/lock"
export namespace StorageNext {
const log = Log.create({ service: "storage" })
type Migration = (dir: string) => Promise<void>
const MIGRATIONS: Migration[] = []
const state = lazy(async () => {
const dir = path.join(Global.Path.data, "storage")
const migration = await Bun.file(path.join(dir, "migration"))
.json()
.then((x) => parseInt(x))
.catch(() => 0)
for (let index = migration; index < MIGRATIONS.length; index++) {
log.info("running migration", { index })
const migration = MIGRATIONS[index]
await migration(dir)
await Bun.write(path.join(dir, "migration"), (index + 1).toString())
}
return {
dir,
}
})
export async function remove(key: string[]) {
const dir = await state().then((x) => x.dir)
const target = path.join(dir, ...key) + ".json"
await fs.unlink(target).catch(() => {})
}
export async function read<T>(key: string[]) {
const dir = await state().then((x) => x.dir)
const target = path.join(dir, ...key) + ".json"
using _ = await Lock.read(target)
return Bun.file(target).json() as Promise<T>
}
export async function update<T>(key: string[], fn: (draft: T) => void) {
const dir = await state().then((x) => x.dir)
const target = path.join(dir, ...key) + ".json"
using _ = await Lock.write("storage")
const content = await Bun.file(target).json()
fn(content)
await Bun.write(target, JSON.stringify(content, null, 2))
return content as T
}
export async function write<T>(key: string[], content: T) {
const dir = await state().then((x) => x.dir)
const target = path.join(dir, ...key) + ".json"
using _ = await Lock.write("storage")
await Bun.write(target, JSON.stringify(content, null, 2))
}
const glob = new Bun.Glob("**/*")
export async function list(prefix: string[]) {
const dir = await state().then((x) => x.dir)
try {
const result = await Array.fromAsync(
glob.scan({
cwd: path.join(dir, ...prefix),
onlyFiles: true,
}),
).then((results) => results.map((x) => [...prefix, ...x.slice(0, -5).split(path.sep)]))
result.sort()
return result
} catch {
return []
}
}
}

View File

@@ -0,0 +1,98 @@
export namespace Lock {
const locks = new Map<
string,
{
readers: number
writer: boolean
waitingReaders: (() => void)[]
waitingWriters: (() => void)[]
}
>()
function get(key: string) {
if (!locks.has(key)) {
locks.set(key, {
readers: 0,
writer: false,
waitingReaders: [],
waitingWriters: [],
})
}
return locks.get(key)!
}
function process(key: string) {
const lock = locks.get(key)
if (!lock || lock.writer || lock.readers > 0) return
// Prioritize writers to prevent starvation
if (lock.waitingWriters.length > 0) {
const nextWriter = lock.waitingWriters.shift()!
nextWriter()
return
}
// Wake up all waiting readers
while (lock.waitingReaders.length > 0) {
const nextReader = lock.waitingReaders.shift()!
nextReader()
}
// Clean up empty locks
if (lock.readers === 0 && !lock.writer && lock.waitingReaders.length === 0 && lock.waitingWriters.length === 0) {
locks.delete(key)
}
}
export async function read(key: string): Promise<Disposable> {
const lock = get(key)
return new Promise((resolve) => {
if (!lock.writer && lock.waitingWriters.length === 0) {
lock.readers++
resolve({
[Symbol.dispose]: () => {
lock.readers--
process(key)
},
})
} else {
lock.waitingReaders.push(() => {
lock.readers++
resolve({
[Symbol.dispose]: () => {
lock.readers--
process(key)
},
})
})
}
})
}
export async function write(key: string): Promise<Disposable> {
const lock = get(key)
return new Promise((resolve) => {
if (!lock.writer && lock.readers === 0) {
lock.writer = true
resolve({
[Symbol.dispose]: () => {
lock.writer = false
process(key)
},
})
} else {
lock.waitingWriters.push(() => {
lock.writer = true
resolve({
[Symbol.dispose]: () => {
lock.writer = false
process(key)
},
})
})
}
})
}
}

63
specs/project.md Normal file
View File

@@ -0,0 +1,63 @@
## project
goal is to let a single instance of opencode be able to run sessions for
multiple projects and different worktrees per project
### api
```
GET /project -> Project[]
POST /project/init -> Project
GET /project/:projectID/session -> Session[]
GET /project/:projectID/session/:sessionID -> Session
POST /project/:projectID/session -> Session
{
id?: string
parentID?: string
directory: string
}
DELETE /project/:projectID/session/:sessionID
POST /project/:projectID/session/:sessionID/init
POST /project/:projectID/session/:sessionID/abort
POST /project/:projectID/session/:sessionID/share
DELETE /project/:projectID/session/:sessionID/share
POST /project/:projectID/session/:sessionID/compact
GET /project/:projectID/session/:sessionID/message -> { info: Message, parts: Part[] }[]
GET /project/:projectID/session/:sessionID/message/:messageID -> { info: Message, parts: Part[] }
POST /project/:projectID/session/:sessionID/message -> { info: Message, parts: Part[] }
POST /project/:projectID/session/:sessionID/revert -> Session
POST /project/:projectID/session/:sessionID/unrevert -> Session
POST /project/:projectID/session/:sessionID/permission/:permissionID -> Session
GET /project/:projectID/session/:sessionID/find/file -> string[]
GET /project/:projectID/session/:sessionID/file -> { type: "raw" | "patch", content: string }
GET /project/:projectID/session/:sessionID/file/status -> File[]
POST /log
// These are awkward
GET /provider?directory=<resolve path> -> Provider
GET /config?directory=<resolve path> -> Config // think only tui uses this?
GET /agent?directory=<resolve path> -> Mode
```