This commit is contained in:
Dax Raad
2025-08-06 23:15:19 -04:00
parent 390b660c81
commit 323ce486a5
20 changed files with 303 additions and 534 deletions
+2 -2
View File
@@ -5,7 +5,7 @@ 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"
import { Paths } from "../project/path"
export namespace Agent {
export const Info = z
@@ -26,7 +26,7 @@ export namespace Agent {
})
export type Info = z.infer<typeof Info>
const state = State.create(
() => Session.use().directory,
() => Paths.use().directory,
async () => {
const cfg = await Config.get()
const result: Record<string, Info> = {
+9 -22
View File
@@ -7,7 +7,7 @@ import path from "path"
import os from "os"
import { z } from "zod"
import { Project } from "../project/project"
import { Worktree } from "../project/worktree"
import { Paths } from "../project/path"
export namespace App {
const log = Log.create({ service: "app" })
@@ -100,7 +100,12 @@ export namespace App {
return ctx.provide(app, async () => {
return Project.provide(project, async () => {
return Worktree.provide(project.worktree, async () => {
return Paths.provide(
{
worktree: app.info.path.root,
directory: app.info.path.cwd,
},
async () => {
try {
const result = await cb(app.info)
return result
@@ -111,28 +116,10 @@ export namespace App {
await entry.shutdown?.(await entry.state)
}
}
},
)
})
})
})
}
export function state<State>(
key: any,
init: (app: Info) => State,
shutdown?: (state: Awaited<State>) => Promise<void>,
) {
return () => {
const app = ctx.use()
const services = app.services
if (!services.has(key)) {
log.info("registering service", { name: key })
services.set(key, {
state: init(app.info),
shutdown,
})
}
return services.get(key)?.state as State
}
}
export function info() {
+1 -9
View File
@@ -1,19 +1,12 @@
import { z, type ZodType } from "zod"
import { App } from "../app/app"
import { Log } from "../util/log"
export namespace Bus {
const log = Log.create({ service: "bus" })
type Subscription = (event: any) => void
const state = App.state("bus", () => {
const subscriptions = new Map<any, Subscription[]>()
return {
subscriptions,
}
})
export type EventDefinition = ReturnType<typeof event>
const registry = new Map<string, EventDefinition>()
@@ -63,7 +56,7 @@ export namespace Bus {
})
const pending = []
for (const key of [def.type, "*"]) {
const match = state().subscriptions.get(key)
const match = subscriptions.get(key)
for (const sub of match ?? []) {
pending.push(sub(payload))
}
@@ -96,7 +89,6 @@ export namespace Bus {
function raw(type: string, callback: (event: any) => void) {
log.info("subscribing", { type })
const subscriptions = state().subscriptions
let match = subscriptions.get(type) ?? []
match.push(callback)
subscriptions.set(type, match)
-2
View File
@@ -1,5 +1,4 @@
import { App } from "../app/app"
import { ConfigHooks } from "../config/hooks"
import { Format } from "../format"
import { LSP } from "../lsp"
import { Plugin } from "../plugin"
@@ -11,7 +10,6 @@ export async function bootstrap<T>(input: App.Input, cb: (app: App.Info) => Prom
Share.init()
Format.init()
Plugin.init()
ConfigHooks.init()
LSP.init()
Snapshot.init()
+8 -32
View File
@@ -14,18 +14,19 @@ 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"
import { Paths } from "../project/path"
export namespace Config {
const log = Log.create({ service: "config" })
export const state = State.create(
() => Project.use().worktree,
() => Paths.use().directory,
async () => {
const paths = Paths.use()
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)
const found = await Filesystem.findUp(file, paths.directory, paths.worktree)
for (const resolved of found.toReversed()) {
result = mergeDeep(result, await loadFile(resolved))
}
@@ -48,7 +49,7 @@ export namespace 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)),
...(await Filesystem.globUp(".opencode/agent/*.md", paths.directory, paths.worktree)),
]
for (const item of markdownAgents) {
const content = await Bun.file(item).text()
@@ -74,7 +75,7 @@ export namespace Config {
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)),
...(await Filesystem.globUp(".opencode/mode/*.md", paths.directory, paths.worktree)),
]
for (const item of markdownModes) {
const content = await Bun.file(item).text()
@@ -100,7 +101,7 @@ export namespace Config {
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)),
...(await Filesystem.globUp(".opencode/plugin/*.ts", paths.directory, paths.worktree)),
].map((x) => "file://" + x),
)
@@ -328,32 +329,7 @@ export namespace Config {
bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
})
.optional(),
experimental: z
.object({
hook: z
.object({
file_edited: z
.record(
z.string(),
z
.object({
command: z.string().array(),
environment: z.record(z.string(), z.string()).optional(),
})
.array(),
)
.optional(),
session_completed: z
.object({
command: z.string().array(),
environment: z.record(z.string(), z.string()).optional(),
})
.array()
.optional(),
})
.optional(),
})
.optional(),
experimental: z.object({}).optional(),
})
.strict()
.openapi({
-55
View File
@@ -1,55 +0,0 @@
import { App } from "../app/app"
import { Bus } from "../bus"
import { File } from "../file"
import { Session } from "../session"
import { Log } from "../util/log"
import { Config } from "./config"
import path from "path"
export namespace ConfigHooks {
const log = Log.create({ service: "config.hooks" })
export function init() {
log.info("init")
Bus.subscribe(File.Event.Edited, async (payload) => {
const cfg = await Config.get()
const ext = path.extname(payload.properties.file)
for (const item of cfg.experimental?.hook?.file_edited?.[ext] ?? []) {
log.info("file_edited", {
file: payload.properties.file,
command: item.command,
})
Bun.spawn({
cmd: item.command.map((x) => x.replace("$FILE", payload.properties.file)),
env: item.environment,
cwd: app.path.cwd,
stdout: "ignore",
stderr: "ignore",
})
}
})
Bus.subscribe(Session.Event.Idle, async (payload) => {
const cfg = await Config.get()
if (cfg.experimental?.hook?.session_completed) {
const session = await Session.get(payload.properties.sessionID)
// Only fire hook for top-level sessions (not subagent sessions)
if (session.parentID) return
for (const item of cfg.experimental.hook.session_completed) {
log.info("session_completed", {
command: item.command,
})
Bun.spawn({
cmd: item.command,
cwd: App.info().path.cwd,
env: item.environment,
stdout: "ignore",
stderr: "ignore",
})
}
}
})
}
}
+2 -1
View File
@@ -4,6 +4,7 @@ import fs from "fs"
import { App } from "../app/app"
import { Log } from "../util/log"
import { Flag } from "../flag/flag"
import { Paths } from "../project/path"
export namespace FileWatcher {
const log = Log.create({ service: "file.watcher" })
@@ -18,7 +19,7 @@ export namespace FileWatcher {
),
}
const state = App.state(
"file.watcher",
() => Paths.use().worktree,
() => {
const app = App.use()
if (!app.info.git) return {}
+7 -2
View File
@@ -7,11 +7,15 @@ import path from "path"
import * as Formatter from "./formatter"
import { Config } from "../config/config"
import { mergeDeep } from "remeda"
import { State } from "../project/state"
import { Paths } from "../project/path"
export namespace Format {
const log = Log.create({ service: "format" })
const state = App.state("format", async () => {
const state = State.create(
() => Paths.use().directory,
async () => {
const enabled: Record<string, boolean> = {}
const cfg = await Config.get()
@@ -35,7 +39,8 @@ export namespace Format {
enabled,
formatters,
}
})
},
)
async function isEnabled(item: Formatter.Info) {
const s = await state()
+2 -2
View File
@@ -7,7 +7,7 @@ import { z } from "zod"
import { Config } from "../config/config"
import { spawn } from "child_process"
import { State } from "../project/state"
import { Worktree } from "../project/worktree"
import { Paths } from "../project/path"
export namespace LSP {
const log = Log.create({ service: "lsp" })
@@ -56,7 +56,7 @@ export namespace LSP {
export type DocumentSymbol = z.infer<typeof DocumentSymbol>
const state = State.create(
() => Worktree.use(),
() => Paths.use().worktree,
async () => {
const clients: LSPClient.Info[] = []
const servers: Record<string, LSPServer.Info> = LSPServer
+2 -1
View File
@@ -9,6 +9,7 @@ import { NamedError } from "../util/error"
import { z } from "zod"
import { Session } from "../session"
import { Bus } from "../bus"
import { State } from "../project/state"
export namespace MCP {
const log = Log.create({ service: "mcp" })
@@ -20,7 +21,7 @@ export namespace MCP {
}),
)
const state = App.state(
const state = State.create(
"mcp",
async () => {
const cfg = await Config.get()
+4 -3
View File
@@ -1,9 +1,10 @@
import { App } from "../app/app"
import { z } from "zod"
import { Bus } from "../bus"
import { Log } from "../util/log"
import { Identifier } from "../id/id"
import { Plugin } from "../plugin"
import { State } from "../project/state"
import { Project } from "../project/project"
export namespace Permission {
const log = Log.create({ service: "permission" })
@@ -35,8 +36,8 @@ export namespace Permission {
),
}
const state = App.state(
"permission",
const state = State.create(
() => Project.use().id,
() => {
const pending: {
[sessionID: string]: {
+8 -4
View File
@@ -1,16 +1,19 @@
import type { Hooks, Plugin as PluginInstance } from "@opencode-ai/plugin"
import { App } from "../app/app"
import { Config } from "../config/config"
import { Bus } from "../bus"
import { Log } from "../util/log"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { Server } from "../server/server"
import { BunProc } from "../bun"
import { State } from "../project/state"
import { Paths } from "../project/path"
export namespace Plugin {
const log = Log.create({ service: "plugin" })
const state = App.state("plugin", async (app) => {
const state = State.create(
() => Paths.directory,
async () => {
const client = createOpencodeClient({
baseUrl: "http://localhost:4096",
fetch: async (...args) => Server.app().fetch(...args),
@@ -27,7 +30,7 @@ export namespace Plugin {
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
const init = await fn({
client,
app,
app: null as any,
$: Bun.$,
})
hooks.push(init)
@@ -37,7 +40,8 @@ export namespace Plugin {
return {
hooks,
}
})
},
)
export async function trigger<
Name extends keyof Required<Hooks>,
+13
View File
@@ -0,0 +1,13 @@
import { Context } from "../util/context"
const context = Context.create<{ directory: string; worktree: string }>("path")
export const Paths = {
provider: context.provide,
get directory() {
return context.use().directory
},
get worktree() {
return context.use().worktree
},
}
+1 -1
View File
@@ -21,7 +21,7 @@ export namespace State {
}
export async function dispose() {
for (const [key, entry] of entries.entries()) {
for (const [_, entry] of entries.entries()) {
if (!entry.dispose) continue
await entry.dispose(await entry.state)
}
@@ -1,7 +0,0 @@
import { Context } from "../util/context"
export namespace Worktree {
const context = Context.create<string>("worktree")
export const use = context.use
export const provide = context.provide
}
+7 -3
View File
@@ -1,5 +1,4 @@
import z from "zod"
import { App } from "../app/app"
import { Config } from "../config/config"
import { mergeDeep, sortBy } from "remeda"
import { NoSuchModelError, type LanguageModel, type Provider as SDK } from "ai"
@@ -10,6 +9,8 @@ import { AuthCopilot } from "../auth/copilot"
import { ModelsDev } from "./models"
import { NamedError } from "../util/error"
import { Auth } from "../auth"
import { State } from "../project/state"
import { Paths } from "../project/path"
export namespace Provider {
const log = Log.create({ service: "provider" })
@@ -216,7 +217,9 @@ export namespace Provider {
},
}
const state = App.state("provider", async () => {
const state = State.create(
() => Paths.directory,
async () => {
const config = await Config.get()
const database = await ModelsDev.get()
@@ -358,7 +361,8 @@ export namespace Provider {
providers,
sdk,
}
})
},
)
export async function list() {
return state().then((state) => state.providers)
+4 -8
View File
@@ -42,16 +42,12 @@ 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"
import { State } from "../project/state"
import { Paths } from "../project/path"
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 - "
@@ -135,8 +131,8 @@ export namespace Session {
),
}
const state = App.state(
"session",
const state = State.create(
() => Paths.directory,
() => {
const pending = new Map<string, AbortController>()
const autoCompacting = new Map<string, boolean>()
+7 -3
View File
@@ -1,7 +1,8 @@
import { App } from "../app/app"
import { Config } from "../config/config"
import z from "zod"
import { Provider } from "../provider/provider"
import { State } from "../project/state"
import { Paths } from "../project/path"
export namespace Mode {
export const Info = z
@@ -22,7 +23,9 @@ export namespace Mode {
ref: "Mode",
})
export type Info = z.infer<typeof Info>
const state = App.state("mode", async () => {
const state = State.create(
() => Paths.directory,
async () => {
const cfg = await Config.get()
const model = cfg.model ? Provider.parseModel(cfg.model) : undefined
const result: Record<string, Info> = {
@@ -62,7 +65,8 @@ export namespace Mode {
}
return result
})
},
)
export async function get(mode: string) {
return state().then((x) => x[mode])
-155
View File
@@ -1,155 +0,0 @@
import { Log } from "../util/log"
import { App } from "../app/app"
import { Bus } from "../bus"
import path from "path"
import z from "zod"
import fs from "fs/promises"
import { MessageV2 } from "../session/message-v2"
import { Identifier } from "../id/id"
export namespace Storage {
const log = Log.create({ service: "storage" })
export const Event = {
Write: Bus.event("storage.write", z.object({ key: z.string(), content: z.any() })),
}
type Migration = (dir: string) => Promise<void>
const MIGRATIONS: Migration[] = [
async (dir: string) => {
try {
const files = new Bun.Glob("session/message/*/*.json").scanSync({
cwd: dir,
absolute: true,
})
for (const file of files) {
const content = await Bun.file(file).json()
if (!content.metadata) continue
log.info("migrating to v2 message", { file })
try {
const result = MessageV2.fromV1(content)
await Bun.write(
file,
JSON.stringify(
{
...result.info,
parts: result.parts,
},
null,
2,
),
)
} catch (e) {
await fs.rename(file, file.replace("storage", "broken"))
}
}
} catch {}
},
async (dir: string) => {
const files = new Bun.Glob("session/message/*/*.json").scanSync({
cwd: dir,
absolute: true,
})
for (const file of files) {
try {
const { parts, ...info } = await Bun.file(file).json()
if (!parts) continue
for (const part of parts) {
const id = Identifier.ascending("part")
await Bun.write(
[dir, "session", "part", info.sessionID, info.id, id + ".json"].join("/"),
JSON.stringify({
...part,
id,
sessionID: info.sessionID,
messageID: info.id,
...(part.type === "tool" ? { callID: part.id } : {}),
}),
)
}
await Bun.write(file, JSON.stringify(info, null, 2))
} catch (e) {}
}
},
async (dir: string) => {
const files = new Bun.Glob("session/message/*/*.json").scanSync({
cwd: dir,
absolute: true,
})
for (const file of files) {
try {
const content = await Bun.file(file).json()
if (content.role === "assistant" && !content.mode) {
log.info("adding mode field to message", { file })
content.mode = "build"
await Bun.write(file, JSON.stringify(content, null, 2))
}
} catch (e) {}
}
},
]
const state = App.state("storage", async () => {
const app = App.info()
const dir = path.normalize(path.join(app.path.data, "storage"))
await fs.mkdir(dir, { recursive: true })
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 removeDir(key: string) {
const dir = await state().then((x) => x.dir)
const target = path.join(dir, key)
await fs.rm(target, { recursive: true, force: true }).catch(() => {})
}
export async function readJSON<T>(key: string) {
const dir = await state().then((x) => x.dir)
return Bun.file(path.join(dir, key + ".json")).json() as Promise<T>
}
export async function writeJSON<T>(key: string, content: T) {
const dir = await state().then((x) => x.dir)
const target = path.join(dir, key + ".json")
const tmp = target + Date.now() + ".tmp"
await Bun.write(tmp, JSON.stringify(content, null, 2))
await fs.rename(tmp, target).catch(() => {})
await fs.unlink(tmp).catch(() => {})
Bus.publish(Event.Write, { key, content })
}
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((items) => items.map((item) => path.join(prefix, item.slice(0, -5))))
result.sort()
return result
} catch {
return []
}
}
}
+7 -3
View File
@@ -1,7 +1,8 @@
import { z } from "zod"
import { Tool } from "./tool"
import DESCRIPTION_WRITE from "./todowrite.txt"
import { App } from "../app/app"
import { State } from "../project/state"
import { Project } from "../project/project"
const TodoInfo = z.object({
content: z.string().describe("Brief description of the task"),
@@ -11,12 +12,15 @@ const TodoInfo = z.object({
})
type TodoInfo = z.infer<typeof TodoInfo>
const state = App.state("todo-tool", () => {
const state = State.create(
() => Project.use().id,
() => {
const todos: {
[sessionId: string]: TodoInfo[]
} = {}
return todos
})
},
)
export const TodoWriteTool = Tool.define("todowrite", {
description: DESCRIPTION_WRITE,