Compare commits

...
Sign in to create a new pull request.

8 commits

Author SHA1 Message Date
Kit Langton
8f39c4bfff
Merge branch 'dev' into kit/skill-lazy-init 2026-03-20 09:17:34 -04:00
Kit Langton
67b1d7ef36
Merge branch 'dev' into kit/skill-lazy-init 2026-03-19 21:16:40 -04:00
Kit Langton
07cdb2668c Merge remote-tracking branch 'origin/dev' into kit/skill-lazy-init
# ------------------------ >8 ------------------------
# Do not modify or remove the line above.
# Everything below it will be ignored.
#
# Conflicts:
#	packages/opencode/src/skill/skill.ts
2026-03-19 19:26:36 -04:00
Kit Langton
be7a9c4987 extract expandHome utility, fix scan error handling scope 2026-03-19 16:54:06 -04:00
Kit Langton
47cb07a8cf effectify Skill.load: replace Effect.promise blob with native Effect operations
Convert add/scan/load from async functions wrapped in Effect.promise to
proper Effect.fn generators using AppFileSystem.Service for isDir, glob,
and up operations. This eliminates the nested Effect.runPromise call for
discovery.pull and enables concurrent skill file processing.
2026-03-19 16:38:04 -04:00
Kit Langton
5f5546ee9b log errors in catchCause instead of silently swallowing 2026-03-19 16:21:57 -04:00
Kit Langton
d3972f7107 use forkScoped + Fiber.join for lazy Skill init (replace ensure pattern) 2026-03-19 16:14:48 -04:00
Kit Langton
b9de3ad370 fix(bus): tighten GlobalBus payload and BusEvent.define types
Constrain BusEvent.define to ZodObject instead of ZodType so TS knows
event properties are always a record. Type GlobalBus payload as
{ type: string; properties: Record<string, unknown> } instead of any.

Refactor watcher test to use Bus.subscribe instead of raw GlobalBus
listener, removing hand-rolled event types and unnecessary casts.
2026-03-19 15:12:21 -04:00
9 changed files with 181 additions and 174 deletions

View file

@ -1,5 +1,5 @@
import z from "zod" import z from "zod"
import type { ZodType } from "zod" import type { ZodObject, ZodRawShape } from "zod"
import { Log } from "../util/log" import { Log } from "../util/log"
export namespace BusEvent { export namespace BusEvent {
@ -9,7 +9,7 @@ export namespace BusEvent {
const registry = new Map<string, Definition>() const registry = new Map<string, Definition>()
export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) { export function define<Type extends string, Properties extends ZodObject<ZodRawShape>>(type: Type, properties: Properties) {
const result = { const result = {
type, type,
properties, properties,

View file

@ -4,7 +4,7 @@ export const GlobalBus = new EventEmitter<{
event: [ event: [
{ {
directory?: string directory?: string
payload: any payload: { type: string; properties: Record<string, unknown> }
}, },
] ]
}>() }>()

View file

@ -1,5 +1,4 @@
import path from "path" import path from "path"
import os from "os"
import z from "zod" import z from "zod"
import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser" import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
@ -109,9 +108,7 @@ export namespace ConfigPaths {
} }
let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "") let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
if (filePath.startsWith("~/")) { filePath = Filesystem.expandHome(filePath)
filePath = path.join(os.homedir(), filePath.slice(2))
}
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath) const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
const fileContent = ( const fileContent = (

View file

@ -124,7 +124,7 @@ export namespace Workspace {
await parseSSE(res.body, stop, (event) => { await parseSSE(res.body, stop, (event) => {
GlobalBus.emit("event", { GlobalBus.emit("event", {
directory: space.id, directory: space.id,
payload: event, payload: event as { type: string; properties: Record<string, unknown> },
}) })
}) })
// Wait 250ms and retry if SSE connection fails // Wait 250ms and retry if SSE connection fails

View file

@ -95,9 +95,7 @@ export namespace InstructionPrompt {
if (config.instructions) { if (config.instructions) {
for (let instruction of config.instructions) { for (let instruction of config.instructions) {
if (instruction.startsWith("https://") || instruction.startsWith("http://")) continue if (instruction.startsWith("https://") || instruction.startsWith("http://")) continue
if (instruction.startsWith("~/")) { instruction = Filesystem.expandHome(instruction)
instruction = path.join(os.homedir(), instruction.slice(2))
}
const matches = path.isAbsolute(instruction) const matches = path.isAbsolute(instruction)
? await Glob.scan(path.basename(instruction), { ? await Glob.scan(path.basename(instruction), {
cwd: path.dirname(instruction), cwd: path.dirname(instruction),

View file

@ -203,7 +203,7 @@ export namespace SessionPrompt {
if (seen.has(name)) return if (seen.has(name)) return
seen.add(name) seen.add(name)
const filepath = name.startsWith("~/") const filepath = name.startsWith("~/")
? path.join(os.homedir(), name.slice(2)) ? Filesystem.expandHome(name)
: path.resolve(Instance.worktree, name) : path.resolve(Instance.worktree, name)
const stats = await fs.stat(filepath).catch(() => undefined) const stats = await fs.stat(filepath).catch(() => undefined)

View file

@ -1,11 +1,11 @@
import os from "os"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import z from "zod" import z from "zod"
import { Effect, Layer, ServiceMap } from "effect" import { Effect, Fiber, Layer, ServiceMap } from "effect"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import type { Agent } from "@/agent/agent" import type { Agent } from "@/agent/agent"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { AppFileSystem } from "@/filesystem"
import { InstanceContext } from "@/effect/instance-context" import { InstanceContext } from "@/effect/instance-context"
import { runPromiseInstance } from "@/effect/runtime" import { runPromiseInstance } from "@/effect/runtime"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
@ -14,7 +14,6 @@ import { PermissionNext } from "@/permission"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { Config } from "../config/config" import { Config } from "../config/config"
import { ConfigMarkdown } from "../config/markdown" import { ConfigMarkdown } from "../config/markdown"
import { Glob } from "../util/glob"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Discovery } from "./discovery" import { Discovery } from "./discovery"
@ -54,11 +53,6 @@ export namespace Skill {
type State = { type State = {
skills: Record<string, Info> skills: Record<string, Info>
dirs: Set<string> dirs: Set<string>
task?: Promise<void>
}
type Cache = State & {
ensure: () => Promise<void>
} }
export interface Interface { export interface Interface {
@ -68,16 +62,35 @@ export namespace Skill {
readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]> readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
} }
const add = async (state: State, match: string) => { export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
const md = await ConfigMarkdown.parse(match).catch(async (err) => {
export const layer: Layer.Layer<Service, never, InstanceContext | Discovery.Service | AppFileSystem.Service> =
Layer.effect(
Service,
Effect.gen(function* () {
const instance = yield* InstanceContext
const discovery = yield* Discovery.Service
const fs = yield* AppFileSystem.Service
const state: State = {
skills: {},
dirs: new Set<string>(),
}
const add = Effect.fn("Skill.add")(function* (match: string) {
const md = yield* Effect.tryPromise(() => ConfigMarkdown.parse(match)).pipe(
Effect.catch((err) =>
Effect.gen(function* () {
const message = ConfigMarkdown.FrontmatterError.isInstance(err) const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message ? err.data.message
: `Failed to parse skill ${match}` : `Failed to parse skill ${match}`
const { Session } = await import("@/session") const { Session } = yield* Effect.promise(() => import("@/session"))
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load skill", { skill: match, err }) log.error("failed to load skill", { skill: match, err })
return undefined return undefined
}) }),
),
)
if (!md) return if (!md) return
@ -99,111 +112,110 @@ export namespace Skill {
location: match, location: match,
content: md.content, content: md.content,
} }
} })
const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => { const scan = Effect.fn("Skill.scan")(function* (
return Glob.scan(pattern, { root: string,
pattern: string,
opts?: { dot?: boolean; scope?: string },
) {
const matches = yield* fs
.glob(pattern, {
cwd: root, cwd: root,
absolute: true, absolute: true,
include: "file", include: "file",
symlink: true, symlink: true,
dot: opts?.dot, dot: opts?.dot,
}) })
.then((matches) => Promise.all(matches.map((match) => add(state, match)))) .pipe(
.catch((error) => { Effect.catch((error) => {
if (!opts?.scope) throw error if (!opts?.scope) return Effect.fail(error)
return Effect.sync(() => {
log.error(`failed to scan ${opts.scope} skills`, { dir: root, error }) log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
return [] as string[]
}) })
} }),
)
// TODO: Migrate to Effect yield* Effect.forEach(matches, (match) => add(match), { concurrency: "unbounded" })
const create = (instance: InstanceContext.Shape, discovery: Discovery.Interface): Cache => { })
const state: State = {
skills: {},
dirs: new Set<string>(),
}
const load = async () => { const load = Effect.fn("Skill.load")(function* () {
// Phase 1: External dirs (global)
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) { if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
for (const dir of EXTERNAL_DIRS) { for (const dir of EXTERNAL_DIRS) {
const root = path.join(Global.Path.home, dir) const root = path.join(Global.Path.home, dir)
if (!(await Filesystem.isDir(root))) continue if (!(yield* fs.isDir(root).pipe(Effect.orDie))) continue
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" }) yield* scan(root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
} }
for await (const root of Filesystem.up({ // Phase 2: External dirs (project, walk up)
const roots = yield* fs
.up({
targets: EXTERNAL_DIRS, targets: EXTERNAL_DIRS,
start: instance.directory, start: instance.directory,
stop: instance.project.worktree, stop: instance.project.worktree,
})) { })
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" }) .pipe(Effect.orDie)
}
yield* Effect.forEach(
roots,
(root) => scan(root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" }),
{ concurrency: "unbounded" },
)
} }
for (const dir of await Config.directories()) { // Phase 3: Config directories
await scan(state, dir, OPENCODE_SKILL_PATTERN) const dirs = yield* Effect.promise(() => Config.directories())
} yield* Effect.forEach(dirs, (dir) => scan(dir, OPENCODE_SKILL_PATTERN), { concurrency: "unbounded" })
const cfg = await Config.get() // Phase 4: Custom paths
const cfg = yield* Effect.promise(() => Config.get())
for (const item of cfg.skills?.paths ?? []) { for (const item of cfg.skills?.paths ?? []) {
const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item const expanded = Filesystem.expandHome(item)
const dir = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded) const dir = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
if (!(await Filesystem.isDir(dir))) { if (!(yield* fs.isDir(dir).pipe(Effect.orDie))) {
log.warn("skill path not found", { path: dir }) log.warn("skill path not found", { path: dir })
continue continue
} }
await scan(state, dir, SKILL_PATTERN) yield* scan(dir, SKILL_PATTERN)
} }
// Phase 5: Remote URLs
for (const url of cfg.skills?.urls ?? []) { for (const url of cfg.skills?.urls ?? []) {
for (const dir of await Effect.runPromise(discovery.pull(url))) { const pullDirs = yield* discovery.pull(url)
for (const dir of pullDirs) {
state.dirs.add(dir) state.dirs.add(dir)
await scan(state, dir, SKILL_PATTERN) yield* scan(dir, SKILL_PATTERN)
} }
} }
log.info("init", { count: Object.keys(state.skills).length }) log.info("init", { count: Object.keys(state.skills).length })
}
const ensure = () => {
if (state.task) return state.task
state.task = load().catch((err) => {
state.task = undefined
throw err
}) })
return state.task
}
return { ...state, ensure } const loadFiber = yield* load().pipe(
} Effect.catchCause((cause) => Effect.sync(() => log.error("init failed", { cause }))),
Effect.forkScoped,
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {} )
export const layer: Layer.Layer<Service, never, InstanceContext | Discovery.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const instance = yield* InstanceContext
const discovery = yield* Discovery.Service
const state = create(instance, discovery)
const get = Effect.fn("Skill.get")(function* (name: string) { const get = Effect.fn("Skill.get")(function* (name: string) {
yield* Effect.promise(() => state.ensure()) yield* Fiber.join(loadFiber)
return state.skills[name] return state.skills[name]
}) })
const all = Effect.fn("Skill.all")(function* () { const all = Effect.fn("Skill.all")(function* () {
yield* Effect.promise(() => state.ensure()) yield* Fiber.join(loadFiber)
return Object.values(state.skills) return Object.values(state.skills)
}) })
const dirs = Effect.fn("Skill.dirs")(function* () { const dirs = Effect.fn("Skill.dirs")(function* () {
yield* Effect.promise(() => state.ensure()) yield* Fiber.join(loadFiber)
return Array.from(state.dirs) return Array.from(state.dirs)
}) })
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) { const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
yield* Effect.promise(() => state.ensure()) yield* Fiber.join(loadFiber)
const list = Object.values(state.skills).toSorted((a, b) => a.name.localeCompare(b.name)) const list = Object.values(state.skills).toSorted((a, b) => a.name.localeCompare(b.name))
if (!agent) return list if (!agent) return list
return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny") return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny")
@ -215,6 +227,7 @@ export namespace Skill {
export const defaultLayer: Layer.Layer<Service, never, InstanceContext> = layer.pipe( export const defaultLayer: Layer.Layer<Service, never, InstanceContext> = layer.pipe(
Layer.provide(Discovery.defaultLayer), Layer.provide(Discovery.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
) )
export async function get(name: string) { export async function get(name: string) {

View file

@ -2,6 +2,7 @@ import { chmod, mkdir, readFile, writeFile } from "fs/promises"
import { createWriteStream, existsSync, statSync } from "fs" import { createWriteStream, existsSync, statSync } from "fs"
import { lookup } from "mime-types" import { lookup } from "mime-types"
import { realpathSync } from "fs" import { realpathSync } from "fs"
import os from "os"
import { dirname, join, relative, resolve as pathResolve } from "path" import { dirname, join, relative, resolve as pathResolve } from "path"
import { Readable } from "stream" import { Readable } from "stream"
import { pipeline } from "stream/promises" import { pipeline } from "stream/promises"
@ -95,6 +96,10 @@ export namespace Filesystem {
} }
} }
export function expandHome(p: string): string {
return p.startsWith("~/") ? join(os.homedir(), p.slice(2)) : p
}
export function mimeType(p: string): string { export function mimeType(p: string): string {
return lookup(p) || "application/octet-stream" return lookup(p) || "application/octet-stream"
} }

View file

@ -5,9 +5,9 @@ import path from "path"
import { Deferred, Effect, Option } from "effect" import { Deferred, Effect, Option } from "effect"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { watcherConfigLayer, withServices } from "../fixture/instance" import { watcherConfigLayer, withServices } from "../fixture/instance"
import { Bus } from "../../src/bus"
import { FileWatcher } from "../../src/file/watcher" import { FileWatcher } from "../../src/file/watcher"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { GlobalBus } from "../../src/bus/global"
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows) // Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
@ -16,7 +16,6 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } }
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" } type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
/** Run `body` with a live FileWatcher service. */ /** Run `body` with a live FileWatcher service. */
@ -36,22 +35,17 @@ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) { function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) {
let done = false let done = false
function on(evt: BusUpdate) { const unsub = Bus.subscribe(FileWatcher.Event.Updated, (evt) => {
if (done) return if (done) return
if (evt.directory !== directory) return if (!check(evt.properties)) return
if (evt.payload.type !== FileWatcher.Event.Updated.type) return hit(evt.properties)
if (!check(evt.payload.properties)) return })
hit(evt.payload.properties)
}
function cleanup() { return () => {
if (done) return if (done) return
done = true done = true
GlobalBus.off("event", on) unsub()
} }
GlobalBus.on("event", on)
return cleanup
} }
function wait(directory: string, check: (evt: WatcherEvent) => boolean) { function wait(directory: string, check: (evt: WatcherEvent) => boolean) {