From 23e6809ea460425779d4729244eefcf01b7f23d9 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 23 Oct 2026 07:53:25 -0400 Subject: [PATCH 1/4] feat(core): watcher-driven skill cache invalidation (#1740) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-Location skill parse cache invalidates on filesystem watch events: the consumer subscribes to the existing Location-scoped watcher's bus events, marks changed files immediately, and flushes debounced (50ms) — dropping the cache entry of every source whose directories contain a changed file so the next list() re-globs it. Reloads stay lazy and coalesced; a consumer failure logs and degrades to the boot-frozen behavior. Tests cover invalidation, add/unlink of skill directories, burst coalescing, disposal, the fail-safe floor, and delivery through the SystemContext + SessionContextEpoch machinery (ContextUpdated event on a real db session). --- packages/app-bundle/manifest.json | 18 +- .../overlay/packages/core/src/skill.ts | 177 +++++++ .../overlay/packages/core/test/skill.test.ts | 501 ++++++++++++++++++ 3 files changed, 692 insertions(+), 4 deletions(-) create mode 100644 packages/app-bundle/overlay/packages/core/src/skill.ts create mode 100644 packages/app-bundle/overlay/packages/core/test/skill.test.ts diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 0a0e9fd5..704de0c8 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -43,6 +43,7 @@ "packages/core/src/question.ts": "11da591147ec49834ab9c1babea2dddbdcd3181d9bd7da0980eede5b87459411", "packages/core/src/runtime-state.ts": "3ef37e03f51b777f3c96ae12a3c855ef5e8a9f3342f697d70935e48bb7cdceaa", "packages/core/src/session.ts": "c4147b433b07f62439054ac3367c4780d203a08f9fa0fcd691e1be653e4794b6", + "packages/core/src/skill.ts": "72356ecd567ab4c90b4f3b7d54710d78d6cd577401c3a0154b0270487a1f54cb", "packages/core/test/database-migration.test.ts": "e5ffa9b36028a8bf31ae7d3eac7fe34fb0566a609325c8dc29e59021d5ee0b1a", "packages/core/test/location-mutation.test.ts": "2c4c8faa220cc8fabfe12d57bd74ec8dd8aac8485d0ecafd2761cdee1806d6b0", "packages/core/test/runtime-state.test.ts": "e532a5aa2d91a9b8bb8ca3b78a5d29644e5d43532c9de133d96e464729e28986", @@ -50,7 +51,9 @@ "packages/core/test/session-prompt.test.ts": "2af05be037473266c7b38721abbf7f865a4dfe35c9e779282cb7f697ba0a2adc", "packages/core/test/session-runner-recorded.test.ts": "962301989a4a315379819bc3d4600d56c3e646b1a9390d02d01556578fc66adc", "packages/core/test/session-runner.test.ts": "4cf0d4e3989a32b0c693473d4f94bf787bd964e79b312a7ee0032305503edb20", + "packages/core/test/skill.test.ts": "10b7f352750c496d24dcff66804fbe127d2f6aa9456edd647d5406231e9b0a61", "packages/core/test/tool-question.test.ts": "4df41393f15104a1d3a9c3d06e30cef515108490b41fcf4d9a7d9399fd4ed945", + "packages/core/test/tool-skill.test.ts": "84bb1900c7e44d03d1a1e3397e069b4df1251fb6a0c77e6384106e20f9aecfc9", "packages/llm/src/cache-policy.ts": "04c1fb5d3d061cfa95d04e54df0a1c2cdaafac921224242446eaa2968bff7857", "packages/llm/test/cache-policy.test.ts": "e921095c4301440c786105063439e49b2fedbaf6d46f30457e0a96388d268f4b", "packages/opencode/script/build.ts": "876e7d569aa71ce060c1d4b24c96e9bf13245cbf4ca743828d645560a95aa641", @@ -332,6 +335,7 @@ "packages/core/src/tool/grep.ts": "db323a91b27727abcee372bf5403f322458ce456c9b581d4f905ab241bea6dd6", "packages/core/src/tool/question.ts": "9b32d02d3d8e18adaeb86a2b344fff0a21fff1955fcacf91b4c79f93c93fd28f", "packages/core/src/tool/read.ts": "ceb37f787cc78a9973dec7c0f8c5fd647baeac979619741d0ff15167e13c6f29", + "packages/core/src/tool/skill.ts": "0845481fd45c3a40200a5e9ebeea4c5b8c1356c6400f9f79bb040c52380fe4a5", "packages/core/src/tool/webfetch.ts": "904846f51dbee2579a36c4856efbfe1442d4ce6a31d6a280cc387c390fd22bef", "packages/core/src/tool/websearch.ts": "bd0502eeb9d6deeea975a21a592040b5b48abd6f2613e39dd6cfbd1a959c950a", "packages/core/src/tool/write.ts": "4759d89e4b6cd5fa7d7de4ba091d2bf859d830cb15038828a15751c80fd9b0ec", @@ -382,6 +386,7 @@ "packages/opencode/src/session/system.ts": "b8708f3e63386e08041368e6dfa887f250bc6a7543ca396bb8b838041e8aeb19", "packages/opencode/src/session/turn-output.ts": "889624ca54a8149d35f200c7efa732771dfca36984e8a322a9781b1a25434762", "packages/opencode/src/session/user-mutation.ts": "8f8cebe1370a8a54361c546fb6777ee4fbdd47d0dec1ef870305ce5dbe232208", + "packages/opencode/src/skill/index.ts": "418b2baadddfa780e2f6964a3dc6f8907ecf63f86e076c175797e24a1b200981", "packages/opencode/src/snapshot/index.ts": "4d76a78c48597bda2379e2c3396f4a53828b21c6ce03e1975d2911150822c6de", "packages/opencode/src/tool/apply_patch.ts": "37535a783d115ac2032def7a28cce56e304f4e7c75d3ecac05b9a37054ae5b4f", "packages/opencode/src/tool/edit.ts": "38aa4ce0751ff99bbb0807bcda52f8b9a1d32b6e342df4cc2aedab1f03d342b1", @@ -1011,7 +1016,7 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-10-22T13:11:47.632Z", + "extracted_at": "2026-10-23T11:53:20.281Z", "per_package": { "packages/app": { "A": 254, @@ -1020,7 +1025,7 @@ }, "packages/core": { "A": 18, - "M": 47, + "M": 51, "D": 0 }, "packages/llm": { @@ -1030,7 +1035,7 @@ }, "packages/opencode": { "A": 98, - "M": 98, + "M": 99, "D": 0 }, "packages/schema": { @@ -1060,7 +1065,7 @@ } }, "counts": { - "overlay_total": 989, + "overlay_total": 994, "deletions": 7, "server_coupled": 0 }, @@ -1100,6 +1105,7 @@ "packages/core/src/question.ts": "M", "packages/core/src/runtime-state.ts": "A", "packages/core/src/session.ts": "M", + "packages/core/src/skill.ts": "M", "packages/core/test/database-migration.test.ts": "M", "packages/core/test/location-mutation.test.ts": "M", "packages/core/test/runtime-state.test.ts": "A", @@ -1107,7 +1113,9 @@ "packages/core/test/session-prompt.test.ts": "M", "packages/core/test/session-runner-recorded.test.ts": "M", "packages/core/test/session-runner.test.ts": "M", + "packages/core/test/skill.test.ts": "M", "packages/core/test/tool-question.test.ts": "M", + "packages/core/test/tool-skill.test.ts": "M", "packages/llm/src/cache-policy.ts": "M", "packages/llm/test/cache-policy.test.ts": "M", "packages/opencode/script/build.ts": "M", @@ -1389,6 +1397,7 @@ "packages/core/src/tool/grep.ts": "M", "packages/core/src/tool/question.ts": "M", "packages/core/src/tool/read.ts": "M", + "packages/core/src/tool/skill.ts": "M", "packages/core/src/tool/webfetch.ts": "M", "packages/core/src/tool/websearch.ts": "M", "packages/core/src/tool/write.ts": "M", @@ -1439,6 +1448,7 @@ "packages/opencode/src/session/system.ts": "M", "packages/opencode/src/session/turn-output.ts": "A", "packages/opencode/src/session/user-mutation.ts": "A", + "packages/opencode/src/skill/index.ts": "M", "packages/opencode/src/snapshot/index.ts": "M", "packages/opencode/src/tool/apply_patch.ts": "M", "packages/opencode/src/tool/edit.ts": "M", diff --git a/packages/app-bundle/overlay/packages/core/src/skill.ts b/packages/app-bundle/overlay/packages/core/src/skill.ts new file mode 100644 index 00000000..119ee81f --- /dev/null +++ b/packages/app-bundle/overlay/packages/core/src/skill.ts @@ -0,0 +1,177 @@ +export * as SkillV2 from "./skill" + +import path from "path" +import { Cause, Context, Effect, Layer, Schema, Stream, Types } from "effect" +import { Skill } from "@opencode-ai/schema/skill" +import { AgentV2 } from "./agent" +import { ConfigMarkdown } from "./config/markdown" +import { makeLocationNode } from "./effect/app-node" +import { FSUtil } from "./fs-util" +import { Watcher } from "./filesystem/watcher" +import { EventV2 } from "./event" +import { PermissionV2 } from "./permission" +import { AbsolutePath } from "./schema" +import { SkillDiscovery } from "./skill/discovery" +import { State } from "./state" + +export const DirectorySource = Skill.DirectorySource +export type DirectorySource = Skill.DirectorySource + +export const UrlSource = Skill.UrlSource +export type UrlSource = Skill.UrlSource + +export const EmbeddedSource = Skill.EmbeddedSource +export type EmbeddedSource = Skill.EmbeddedSource + +export const Source = Skill.Source +export type Source = typeof Source.Type + +export const Info = Skill.Info +export type Info = Skill.Info + +export const available = (skills: ReadonlyArray, agent: AgentV2.Info) => + skills.filter((skill) => PermissionV2.evaluate("skill", skill.name, agent.permissions).effect !== "deny") + +const Frontmatter = Schema.Struct({ + name: Schema.String.pipe(Schema.optional), + description: Schema.String.pipe(Schema.optional), + slash: Schema.Boolean.pipe(Schema.optional), +}) +const decodeFrontmatter = Schema.decodeUnknownOption(Frontmatter) + +export type Data = { + sources: Types.DeepMutable[] +} + +export type Draft = { + source: (source: Source) => void + list: () => readonly Source[] +} + +export interface Interface extends State.Transformable { + readonly sources: () => Effect.Effect + readonly list: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Skill") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const discovery = yield* SkillDiscovery.Service + const fs = yield* FSUtil.Service + const events = yield* EventV2.Service + + const state = State.create({ + initial: () => ({ sources: [] }), + draft: (draft) => ({ + source: (source) => { + if (draft.sources.some((item) => Source.equals(item, source))) return + draft.sources.push(source as Types.DeepMutable) + }, + list: () => draft.sources as Source[], + }), + }) + + const load = Effect.fn("SkillV2.load")(function* (source: Source) { + const skills: Info[] = [] + if (source.type === "embedded") return { skills: [source.skill], directories: [] as string[] } + const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url) + for (const directory of directories) { + const files = yield* fs + .glob("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true }) + .pipe(Effect.catch(() => Effect.succeed([] as string[]))) + for (const filepath of files.toSorted()) { + const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!content) continue + const markdown = ConfigMarkdown.parseOption(content) + if (!markdown) continue + const frontmatter = decodeFrontmatter(markdown.data).valueOrUndefined + if (!frontmatter) continue + const name = + frontmatter.name !== undefined + ? frontmatter.name + : path.dirname(filepath) === directory + ? path.basename(filepath, ".md") + : undefined + if (!name) continue + skills.push({ + name, + description: frontmatter.description, + slash: frontmatter.slash, + location: AbsolutePath.make(filepath), + content: markdown.content, + }) + } + } + return { skills, directories } + }) + + // Local skills are live (#1740): the Location-scoped filesystem watcher + // publishes per-file events on the bus, and a debounced flush drops the + // cache entry of every source whose directories contain a changed file, + // so the next list() re-globs it. Watcher-disabled environments and files + // outside every source directory keep the boot-frozen behavior. V1/TUI + // (packages/opencode/src/skill) stays boot-frozen by design. + const cache = new Map() + const pending = new Set() + const flush = () => { + if (pending.size === 0) return + const files = Array.from(pending) + pending.clear() + for (const source of state.get().sources) { + if (source.type === "embedded") continue + const key = Source.key(source) + const entry = cache.get(key) + if (!entry) continue + const directories = source.type === "directory" ? [source.path] : entry.directories + if (directories.some((directory) => files.some((file) => FSUtil.contains(directory, file)))) { + cache.delete(key) + } + } + } + yield* events + .subscribe(Watcher.Event.Updated) + .pipe( + Stream.mapEffect((event) => + Effect.sync(() => { + pending.add(event.data.file) + }), + ), + // The flush is debounced, not the marking: every event lands in + // `pending` immediately, and a save storm coalesces into one flush — + // the reload itself is already lazy (one re-parse per invalidated + // source, at the next list()). + Stream.debounce("50 millis"), + Stream.runForEach(() => Effect.sync(flush)), + Effect.catchCause((cause) => + Effect.logError("skill cache invalidation stopped; skills stay boot-frozen until restart", { + cause: Cause.pretty(cause), + }), + ), + Effect.forkScoped, + ) + + const list = Effect.fn("SkillV2.list")(function* () { + const skills = new Map() + for (const source of state.get().sources) { + const key = Source.key(source) + const loaded = cache.get(key) ?? (yield* load(source)) + cache.set(key, loaded) + for (const skill of loaded.skills) skills.set(skill.name, skill) + } + return Array.from(skills.values()) + }) + + return Service.of({ + transform: state.transform, + reload: state.reload, + sources: Effect.fn("SkillV2.sources")(function* () { + return state.get().sources + }), + list, + }) + }), +) + +export const node = makeLocationNode({ service: Service, layer, deps: [SkillDiscovery.node, FSUtil.node, EventV2.node] }) diff --git a/packages/app-bundle/overlay/packages/core/test/skill.test.ts b/packages/app-bundle/overlay/packages/core/test/skill.test.ts new file mode 100644 index 00000000..fdb352df --- /dev/null +++ b/packages/app-bundle/overlay/packages/core/test/skill.test.ts @@ -0,0 +1,501 @@ +import { $ } from "bun" +import { describe, expect } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { ConfigProvider, Deferred, Effect, Fiber, Layer, Option, Stream } from "effect" +import type { Glob } from "@opencode-ai/core/util/glob" +import { Database } from "@opencode-ai/core/database/database" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { EventV2 } from "@opencode-ai/core/event" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Watcher } from "@opencode-ai/core/filesystem/watcher" +import { Global } from "@opencode-ai/core/global" +import { Location } from "@opencode-ai/core/location" +import { Config } from "@opencode-ai/core/config" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionContextEpoch } from "@opencode-ai/core/session/context-epoch" +import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { AgentV2 } from "@opencode-ai/core/agent" +import { SkillV2 } from "@opencode-ai/core/skill" +import { SkillGuidance } from "@opencode-ai/core/skill/guidance" +import { SystemContext } from "@opencode-ai/core/system-context" +import { location } from "./fixture/location" +import { tmpdir } from "./fixture/tmpdir" +import { testEffect, it } from "./lib/effect" + +// Live skills (#1740): the per-source skill parse cache invalidates on +// filesystem watch events, so a merged SKILL.md edit reaches the next +// list()/invocation of a running session instead of waiting for a restart. + +type WatcherEvent = { file: string; event: "add" | "change" | "unlink" } + +const skillFile = (root: string, name: string, description: string, body: string) => + Effect.promise(() => + fs + .mkdir(path.join(root, name), { recursive: true }) + .then(() => + fs.writeFile( + path.join(root, name, "SKILL.md"), + `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`, + ), + ), + ) + +const rewrite = (root: string, name: string, description: string, body: string) => + Effect.promise(() => + fs.writeFile(path.join(root, name, "SKILL.md"), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`), + ) + +const skillsOf = (list: readonly SkillV2.Info[]) => + Object.fromEntries(list.map((skill) => [skill.name, skill])) + +const registerSource = (root: string) => + Effect.flatMap(SkillV2.Service, (skills) => + skills.transform((draft) => { + draft.source(SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(root) })) + }), + ) + +const publish = (file: string, event: "add" | "change" | "unlink") => + Effect.flatMap(EventV2.Service, (events) => events.publish(Watcher.Event.Updated, { file, event })).pipe( + Effect.asVoid, + ) + +const withTmp = (use: (tmp: { path: string }) => Effect.Effect) => + Effect.acquireRelease(Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]())).pipe( + Effect.flatMap(use), + ) + +const globalLayer = [Global.node, Global.layerWith({ config: "/global" })] as const + +const itSkill = testEffect(AppNodeBuilder.build(LayerNode.group([SkillV2.node, EventV2.node]), [globalLayer])) +const itGuidance = testEffect( + AppNodeBuilder.build(LayerNode.group([SkillGuidance.node, SkillV2.node, EventV2.node, Database.node]), [globalLayer]), +) + +describe("SkillV2 cache", () => { + itSkill.live("serves fresh skills after a change event under the source directory", () => + withTmp((tmp) => + Effect.gen(function* () { + const root = path.join(tmp.path, "skills") + yield* skillFile(root, "my-skill", "old description", "old body") + yield* registerSource(root) + + const skills = yield* SkillV2.Service + const before = skillsOf(yield* skills.list()) + expect(before["my-skill"]?.description).toBe("old description") + expect(before["my-skill"]?.content).toContain("old body") + + yield* rewrite(root, "my-skill", "new description", "new body") + yield* publish(path.join(root, "my-skill", "SKILL.md"), "change") + yield* Effect.sleep("200 millis") + + const after = skillsOf(yield* skills.list()) + expect(after["my-skill"]?.description).toBe("new description") + expect(after["my-skill"]?.content).toContain("new body") + }), + ), + ) + + itSkill.live("ignores watch events for files outside the source directories", () => + withTmp((tmp) => + Effect.gen(function* () { + const root = path.join(tmp.path, "skills") + yield* skillFile(root, "my-skill", "old description", "old body") + yield* registerSource(root) + + const skills = yield* SkillV2.Service + expect(skillsOf(yield* skills.list())["my-skill"]?.description).toBe("old description") + + yield* rewrite(root, "my-skill", "new description", "new body") + yield* publish(path.join(tmp.path, "unrelated.txt"), "change") + yield* Effect.sleep("200 millis") + + expect(skillsOf(yield* skills.list())["my-skill"]?.description).toBe("old description") + }), + ), + ) + + itSkill.live("picks up added and removed skill directories on add/unlink events", () => + withTmp((tmp) => + Effect.gen(function* () { + const root = path.join(tmp.path, "skills") + yield* skillFile(root, "my-skill", "does things", "body") + yield* registerSource(root) + + const skills = yield* SkillV2.Service + expect((yield* skills.list()).map((skill) => skill.name)).toEqual(["my-skill"]) + + yield* skillFile(root, "added-skill", "added later", "added body") + yield* publish(path.join(root, "added-skill", "SKILL.md"), "add") + yield* Effect.sleep("200 millis") + expect((yield* skills.list()).map((skill) => skill.name)).toEqual(["added-skill", "my-skill"]) + + yield* Effect.promise(() => fs.rm(path.join(root, "added-skill"), { recursive: true, force: true })) + yield* publish(path.join(root, "added-skill", "SKILL.md"), "unlink") + yield* Effect.sleep("200 millis") + expect((yield* skills.list()).map((skill) => skill.name)).toEqual(["my-skill"]) + }), + ), + ) + + // #1740 fail-safe floor: with no watch events the cache stays boot-frozen, + // exactly like watcher-disabled environments (no native binding, flag off). + itSkill.live("stays boot-frozen when no watch events arrive", () => + withTmp((tmp) => + Effect.gen(function* () { + const root = path.join(tmp.path, "skills") + yield* skillFile(root, "my-skill", "old description", "old body") + yield* registerSource(root) + + const skills = yield* SkillV2.Service + expect(skillsOf(yield* skills.list())["my-skill"]?.description).toBe("old description") + + yield* rewrite(root, "my-skill", "new description", "new body") + yield* Effect.sleep("200 millis") + + expect(skillsOf(yield* skills.list())["my-skill"]?.description).toBe("old description") + }), + ), + ) +}) + +// Burst coalescing: the reload count (glob calls) is observable only through a +// counting FSUtil replacement, so this test drives its own layer tree. +describe("SkillV2 invalidation consumer", () => { + it.live("coalesces an event burst into a single reload", () => + Effect.gen(function* () { + const globs = { count: 0 } + const countingFS = Layer.effect( + FSUtil.Service, + FSUtil.Service.pipe( + Effect.map((fs) => + FSUtil.Service.of({ + ...fs, + glob: (pattern: string, options?: Glob.Options) => + Effect.sync(() => { + globs.count++ + }).pipe(Effect.andThen(fs.glob(pattern, options))), + }), + ), + ), + ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) + + const layer = AppNodeBuilder.build(LayerNode.group([SkillV2.node, EventV2.node]), [ + globalLayer, + [FSUtil.node, countingFS], + ]) + yield* withTmp((tmp) => + Effect.gen(function* () { + const root = path.join(tmp.path, "skills") + yield* skillFile(root, "my-skill", "old description", "old body") + yield* skillFile(root, "other-skill", "old description", "old body") + yield* registerSource(root) + + const skills = yield* SkillV2.Service + expect((yield* skills.list()).length).toBe(2) + expect(globs.count).toBe(1) + + const file = path.join(root, "my-skill", "SKILL.md") + yield* publish(file, "change") + yield* publish(file, "change") + yield* publish(path.join(root, "other-skill", "SKILL.md"), "change") + yield* Effect.sleep("200 millis") + expect((yield* skills.list()).length).toBe(2) + + // One burst → one debounced flush → one re-glob of the source. + expect(globs.count).toBe(2) + }), + ).pipe(Effect.provide(layer), Effect.scoped) + }), + ) + + it.live("stops consuming watch events when the location scope closes", () => + Effect.gen(function* () { + const active = { count: 0 } + const countingEvents = Layer.effect( + EventV2.Service, + EventV2.Service.pipe( + Effect.map((real) => + EventV2.Service.of({ + ...real, + subscribe: (definition) => + Stream.unwrap( + Effect.acquireRelease( + Effect.sync(() => { + active.count++ + }), + () => + Effect.sync(() => { + active.count-- + }), + ).pipe(Effect.as(real.subscribe(definition))), + ), + }), + ), + ), + ).pipe(Layer.provide(LayerNode.compile(EventV2.node))) + + const layer = AppNodeBuilder.build(LayerNode.group([SkillV2.node, EventV2.node]), [ + globalLayer, + [EventV2.node, countingEvents], + ]) + yield* withTmp((tmp) => + Effect.gen(function* () { + const root = path.join(tmp.path, "skills") + yield* skillFile(root, "my-skill", "does things", "body") + yield* registerSource(root) + + const skills = yield* SkillV2.Service + expect((yield* skills.list()).length).toBe(1) + // The consumer's subscription is live for as long as the layer holds it. + yield* Effect.sleep("100 millis") + expect(active.count).toBe(1) + }), + ).pipe(Effect.provide(layer), Effect.scoped) + + // Location disposal interrupts the consumer — no dangling subscription. + expect(active.count).toBe(0) + }), + ) + + it.live("degrades to the frozen cache when the event stream fails", () => + Effect.gen(function* () { + const attempts = { count: 0 } + const failingEvents = Layer.effect( + EventV2.Service, + EventV2.Service.pipe( + Effect.map((real) => + EventV2.Service.of({ + ...real, + subscribe: (definition) => + Stream.unwrap( + Effect.acquireRelease( + Effect.sync(() => { + attempts.count++ + }), + () => Effect.void, + ).pipe(Effect.as(Stream.fail(new Error("watch events unavailable")).pipe(Stream.orDie))), + ), + }), + ), + ), + ).pipe(Layer.provide(LayerNode.compile(EventV2.node))) + + const layer = AppNodeBuilder.build(LayerNode.group([SkillV2.node, EventV2.node]), [ + globalLayer, + [EventV2.node, failingEvents], + ]) + yield* withTmp((tmp) => + Effect.gen(function* () { + const root = path.join(tmp.path, "skills") + yield* skillFile(root, "my-skill", "old description", "old body") + yield* registerSource(root) + + const skills = yield* SkillV2.Service + expect(skillsOf(yield* skills.list())["my-skill"]?.description).toBe("old description") + yield* Effect.sleep("100 millis") + // The consumer died (one attempt) but the service keeps serving. + expect(attempts.count).toBe(1) + expect(skillsOf(yield* skills.list())["my-skill"]?.description).toBe("old description") + }), + ).pipe(Effect.provide(layer), Effect.scoped) + }), + ) +}) + +const selection = { id: AgentV2.ID.make("build"), info: AgentV2.Info.empty(AgentV2.ID.make("build")) } + +describe("SkillGuidance epoch delivery", () => { + itGuidance.live("reconciles a mid-session skill edit into a context update", () => + withTmp((tmp) => + Effect.gen(function* () { + const root = path.join(tmp.path, "skills") + yield* skillFile(root, "my-skill", "old description", "old body") + yield* registerSource(root) + + const guidance = yield* SkillGuidance.Service + const initialized = yield* SystemContext.initialize(yield* guidance.load(selection)) + expect(initialized.baseline).toContain("old description") + + yield* rewrite(root, "my-skill", "new description", "new body") + yield* publish(path.join(root, "my-skill", "SKILL.md"), "change") + yield* Effect.sleep("200 millis") + + const result = yield* SystemContext.reconcile(yield* guidance.load(selection), initialized.snapshot) + expect(result).toMatchObject({ _tag: "Updated" }) + if (result._tag === "Updated") { + expect(result.text).toContain("The available skills have changed") + expect(result.text).toContain("new description") + } + }), + ), + ) + + itGuidance.live("delivers the update through SessionContextEpoch as a ContextUpdated event", () => + withTmp((tmp) => + Effect.gen(function* () { + const root = path.join(tmp.path, "skills") + yield* skillFile(root, "my-skill", "old description", "old body") + yield* registerSource(root) + + const { db } = yield* Database.Service + const events = yield* EventV2.Service + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + const sessionID = SessionV2.ID.make("ses_skill_live_epoch") + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: sessionID, + directory: "/project", + title: "test", + version: "test", + }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + + const guidance = yield* SkillGuidance.Service + const context = Effect.suspend(() => guidance.load(selection)) + const first = yield* SessionContextEpoch.prepare(db, events, context, sessionID) + expect(first.baseline).toContain("old description") + + const updates: string[] = [] + const fiber = yield* events + .subscribe(SessionEvent.ContextUpdated) + .pipe( + Stream.runForEach((event) => + Effect.sync(() => { + updates.push(event.data.text) + }), + ), + Effect.forkScoped, + ) + + yield* rewrite(root, "my-skill", "new description", "new body") + yield* publish(path.join(root, "my-skill", "SKILL.md"), "change") + yield* Effect.sleep("200 millis") + + const second = yield* SessionContextEpoch.prepare(db, events, context, sessionID) + // The baseline is not rewritten — the change arrives as a separate + // chronological ContextUpdated event, exactly as a session sees it. + expect(second.baseline).toBe(first.baseline) + expect(updates).toHaveLength(1) + expect(updates[0]).toContain("The available skills have changed") + expect(updates[0]).toContain("new description") + + yield* Fiber.interrupt(fiber) + }), + ), + ) +}) + +// End-to-end through the real @parcel/watcher binding: an actual disk edit (no +// synthetic bus publish) invalidates the cache. +const describeWatcher = Watcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip + +const waitForEvent = (check: (data: WatcherEvent) => boolean, trigger: Effect.Effect) => + Effect.gen(function* () { + const events = yield* EventV2.Service + const deferred = yield* Deferred.make() + const fiber = yield* events + .subscribe(Watcher.Event.Updated) + .pipe( + Stream.runForEach((event) => { + if (!check(event.data)) return Effect.void + return Deferred.succeed(deferred, event.data).pipe(Effect.asVoid) + }), + Effect.forkScoped, + ) + yield* Effect.yieldNow + const option = yield* trigger.pipe( + Effect.andThen(Deferred.await(deferred)), + Effect.timeoutOption("2 seconds"), + ) + yield* Fiber.interrupt(fiber) + return Option.isSome(option) + }) + +describeWatcher("SkillV2 native watcher end-to-end", () => { + it.live("invalidates the skill cache from real filesystem events", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease(Effect.promise(() => tmpdir()), (t) => + Effect.promise(() => t[Symbol.asyncDispose]()), + ) + const root = path.join(tmp.path, "skills") + yield* skillFile(root, "my-skill", "old description", "old body") + yield* Effect.promise(async () => { + await $`git init`.cwd(tmp.path).quiet() + await $`git config core.fsmonitor false`.cwd(tmp.path).quiet() + await $`git config user.email test@opencode.test`.cwd(tmp.path).quiet() + await $`git config user.name Test`.cwd(tmp.path).quiet() + await $`git commit --allow-empty -m root`.cwd(tmp.path).quiet() + }) + const vcs = { type: "git" as const, store: AbsolutePath.make(path.join(tmp.path, ".git")) } + + const configLayer = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })) + const flagsLayer = ConfigProvider.layer( + ConfigProvider.fromUnknown({ OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false" }), + ) + const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) }, { vcs })), + ) + const layer = AppNodeBuilder.build( + LayerNode.group([Watcher.node, SkillV2.node, FSUtil.node, EventV2.node]), + [ + [Config.node, configLayer], + [Location.node, locationLayer], + globalLayer, + ], + ).pipe(Layer.provide(flagsLayer)) + + yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + yield* registerSource(root) + + // Wait for the watcher subscription to go live before editing: retry + // fresh probe files until one produces an event (bounded). + const readyDeadline = Date.now() + 5_000 + let ready = false + while (!ready && Date.now() < readyDeadline) { + const probe = path.join(tmp.path, `.watcher-probe-${Math.random().toString(36).slice(2)}`) + ready = yield* waitForEvent((data) => data.file === probe, fs.writeFileString(probe, "ready")) + yield* fs.remove(probe, { force: true }).pipe(Effect.ignore) + } + expect(ready).toBe(true) + + const skills = yield* SkillV2.Service + expect(skillsOf(yield* skills.list())["my-skill"]?.description).toBe("old description") + + yield* fs.writeFileString( + path.join(root, "my-skill", "SKILL.md"), + `---\nname: my-skill\ndescription: new description\n---\n\nnew body\n`, + ) + + // The real watcher publishes the change; the debounced consumer then + // invalidates. Poll list() until the edit lands (bounded). + const pollDeadline = Date.now() + 5_000 + let current = skillsOf(yield* skills.list()) + while (current["my-skill"]?.description !== "new description" && Date.now() < pollDeadline) { + yield* Effect.sleep("100 millis") + current = skillsOf(yield* skills.list()) + } + expect(current["my-skill"]?.description).toBe("new description") + expect(current["my-skill"]?.content).toContain("new body") + }).pipe(Effect.provide(layer), Effect.scoped) + }), + ) +}) From 212989e17c493cf10dceed9cdf49b1c222bab4b5 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 23 Oct 2026 07:53:35 -0400 Subject: [PATCH 2/4] feat(core): skill tool re-reads body content per invocation (#1740) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skill tool serves the SKILL.md body from disk at invocation time via a new SkillTool.liveContent seam, matching its fresh companion-file glob — the parse cache only pins name/description metadata between watcher invalidations. A failed read falls back to the cached body, never a tool failure. --- packages/app-bundle/manifest.json | 2 +- .../overlay/packages/core/src/tool/skill.ts | 124 ++++++++++++++++++ .../packages/core/test/tool-skill.test.ts | 56 ++++++++ 3 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 packages/app-bundle/overlay/packages/core/src/tool/skill.ts create mode 100644 packages/app-bundle/overlay/packages/core/test/tool-skill.test.ts diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 704de0c8..33d91423 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -1016,7 +1016,7 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-10-23T11:53:20.281Z", + "extracted_at": "2026-10-23T11:53:31.348Z", "per_package": { "packages/app": { "A": 254, diff --git a/packages/app-bundle/overlay/packages/core/src/tool/skill.ts b/packages/app-bundle/overlay/packages/core/src/tool/skill.ts new file mode 100644 index 00000000..67ae9997 --- /dev/null +++ b/packages/app-bundle/overlay/packages/core/src/tool/skill.ts @@ -0,0 +1,124 @@ +export * as SkillTool from "./skill" + +import path from "path" +import { ToolFailure } from "@opencode-ai/llm" +import { Effect, Layer, Schema } from "effect" +import { makeLocationNode } from "../effect/app-node" +import { ConfigMarkdown } from "../config/markdown" +import { FSUtil } from "../fs-util" +import { SkillV2 } from "../skill" +import { PermissionV2 } from "../permission" +import { ToolRegistry } from "./registry" +import { Tool } from "./tool" +import { Tools } from "./tools" + +export const name = "skill" +const FILE_LIMIT = 10 + +export const Input = Schema.Struct({ + name: Schema.String.annotate({ description: "The name of the skill from the available skills list" }), +}) + +export const Output = Schema.Struct({ + name: Schema.String, + directory: Schema.String, + output: Schema.String, +}) + +export const description = [ + "Load a specialized skill when the task at hand matches one of the available skills in the system context.", + "", + "Use this tool to inject the skill's instructions and resources into the current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc. in the same directory as the skill.", + "", + "The skill name must match one of the available skills in the system context.", +].join("\n") + +export const toModelOutput = (skill: SkillV2.Info, files: ReadonlyArray) => { + const directory = path.dirname(skill.location) + return [ + ``, + `# Skill: ${skill.name}`, + "", + skill.content.trim(), + "", + `Base directory for this skill: ${directory}`, + "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.", + "Note: file list is sampled.", + "", + "", + ...files.map((file) => `${file}`), + "", + "", + ].join("\n") +} + +// #1740: the body is re-read from disk per invocation. The parse cache pins +// name/description metadata between watcher invalidations, but a merged body +// edit is served immediately — matching the fresh companion-file glob below. A +// failed read falls back to the cached body, never the tool's failure. +export const liveContent = (fs: FSUtil.Interface, skill: SkillV2.Info): Effect.Effect => + Effect.gen(function* () { + const fresh = yield* fs.readFileStringSafe(skill.location).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (fresh === undefined) return skill + const markdown = ConfigMarkdown.parseOption(fresh) + if (!markdown) return skill + return { ...skill, content: markdown.content } + }) + +const unableToLoad = (name: string, error?: unknown) => + new ToolFailure({ message: `Unable to load skill ${name}`, error }) + +const layer = Layer.effectDiscard( + Effect.gen(function* () { + const tools = yield* Tools.Service + const fs = yield* FSUtil.Service + const skills = yield* SkillV2.Service + const permission = yield* PermissionV2.Service + yield* tools + .register({ + [name]: Tool.make({ + description, + input: Input, + output: Output, + toModelOutput: ({ output }) => [{ type: "text", text: output.output }], + execute: (input, context) => + Effect.gen(function* () { + const current = yield* skills.list() + const skill = current.find((skill) => skill.name === input.name) + if (!skill) return yield* unableToLoad(input.name) + return yield* Effect.gen(function* () { + yield* permission.assert({ + action: name, + resources: [skill.name], + save: [skill.name], + sessionID: context.sessionID, + agent: context.agent, + source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, + }) + const served = yield* liveContent(fs, skill) + const directory = path.dirname(served.location) + const files = + path.basename(served.location) === "SKILL.md" + ? (yield* fs.glob("**/*", { cwd: directory, absolute: true, include: "file", dot: true })) + .filter((file) => path.basename(file) !== "SKILL.md") + .toSorted() + .slice(0, FILE_LIMIT) + : [] + return { + name: served.name, + directory, + output: toModelOutput(served, files), + } + }).pipe(Effect.mapError((error) => unableToLoad(input.name, error))) + }), + }), + }) + .pipe(Effect.orDie) + }), +) + +export const node = makeLocationNode({ + name: "tool/skill", + layer, + deps: [ToolRegistry.node, FSUtil.node, SkillV2.node, PermissionV2.node], +}) diff --git a/packages/app-bundle/overlay/packages/core/test/tool-skill.test.ts b/packages/app-bundle/overlay/packages/core/test/tool-skill.test.ts new file mode 100644 index 00000000..02c8c19b --- /dev/null +++ b/packages/app-bundle/overlay/packages/core/test/tool-skill.test.ts @@ -0,0 +1,56 @@ +import { describe, expect } from "bun:test" +import path from "path" +import { Effect } from "effect" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SkillV2 } from "@opencode-ai/core/skill" +import { SkillTool } from "@opencode-ai/core/tool/skill" +import { tmpdir } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" + +// #1740: the skill tool re-reads the SKILL.md body per invocation, so a fresh +// edit is served even before the watcher invalidates the parse cache — the +// cache only pins name/description metadata between invalidations. + +const it = testEffect(LayerNode.compile(FSUtil.node)) + +const skill = (location: string, content: string): SkillV2.Info => ({ + name: "my-skill", + description: "does things", + slash: undefined, + location: AbsolutePath.make(location), + content, +}) + +describe("SkillTool live content", () => { + it.live("re-reads the skill body per invocation", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease(Effect.promise(() => tmpdir()), (t) => + Effect.promise(() => t[Symbol.asyncDispose]()), + ) + const file = path.join(tmp.path, "SKILL.md") + yield* Effect.promise(() => + Bun.write(file, `---\nname: my-skill\ndescription: does things\n---\n\nfresh body\n`), + ) + + const fs = yield* FSUtil.Service + const served = yield* SkillTool.liveContent(fs, skill(file, "stale body")) + expect(served.content).toContain("fresh body") + expect(served.name).toBe("my-skill") + }), + ) + + it.live("falls back to the cached body when the file is gone", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease(Effect.promise(() => tmpdir()), (t) => + Effect.promise(() => t[Symbol.asyncDispose]()), + ) + const file = path.join(tmp.path, "missing", "SKILL.md") + + const fs = yield* FSUtil.Service + const served = yield* SkillTool.liveContent(fs, skill(file, "cached body")) + expect(served.content).toBe("cached body") + }), + ) +}) From f645576a3a3f2a3659c9030a67f9cf4b7dc288b1 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 23 Oct 2026 07:53:48 -0400 Subject: [PATCH 3/4] docs(opencode): mark the v1 skill tree boot-frozen (#1740) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v1/TUI skill cache stays deliberately boot-frozen — InstanceState loads once per directory and never re-reads. Live watcher-driven invalidation ships in the V2 session core only; document that boundary honestly at the code site so no silent parity is claimed. --- packages/app-bundle/manifest.json | 2 +- .../packages/opencode/src/skill/index.ts | 360 ++++++++++++++++++ 2 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 packages/app-bundle/overlay/packages/opencode/src/skill/index.ts diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 33d91423..287a950f 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -1016,7 +1016,7 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-10-23T11:53:31.348Z", + "extracted_at": "2026-10-23T11:53:43.831Z", "per_package": { "packages/app": { "A": 254, diff --git a/packages/app-bundle/overlay/packages/opencode/src/skill/index.ts b/packages/app-bundle/overlay/packages/opencode/src/skill/index.ts new file mode 100644 index 00000000..e3a0765c --- /dev/null +++ b/packages/app-bundle/overlay/packages/opencode/src/skill/index.ts @@ -0,0 +1,360 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import path from "path" +import { Effect, Layer, Context, Schema } from "effect" +import { NamedError } from "@opencode-ai/core/util/error" +import type { Agent } from "@/agent/agent" +import { EventV2Bridge } from "@/event-v2-bridge" +import { InstanceState } from "@/effect/instance-state" +import { Global } from "@opencode-ai/core/global" +import { SkillPlugin } from "@opencode-ai/core/plugin/skill" +import { Permission } from "@/permission" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Config } from "@/config/config" +import { FrontmatterError } from "@opencode-ai/core/v1/config/error" +import { ConfigMarkdown } from "@/config/markdown" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { Glob } from "@opencode-ai/core/util/glob" +import { Discovery } from "./discovery" +import { isRecord } from "@/util/record" +import { escapeHtml } from "@/util/html" + +const CLAUDE_EXTERNAL_DIR = ".claude" +const AGENTS_EXTERNAL_DIR = ".agents" +const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md" +const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md" +const SKILL_PATTERN = "**/SKILL.md" + +// Built-in skill that ships with opencode. The model's intuition for what an +// opencode.json should look like is often wrong, and opencode hard-fails on +// invalid config, so users hit cryptic startup errors. Loading this skill +// when the model is asked to touch opencode's own config files gives it the +// actual schemas instead of guesses. +const CUSTOMIZE_OPENCODE_SKILL_NAME = "customize-opencode" +const CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION = + "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself." +const CUSTOMIZE_OPENCODE_SKILL_BODY = SkillPlugin.CustomizeOpencodeContent + +export const Info = Schema.Struct({ + name: Schema.String, + description: Schema.optional(Schema.String), + location: Schema.String, + content: Schema.String, +}) +export type Info = Schema.Schema.Type + +const Issue = Schema.StructWithRest( + Schema.Struct({ + message: Schema.String, + path: Schema.Array(Schema.String), + }), + [Schema.Record(Schema.String, Schema.Unknown)], +) + +function isSkillFrontmatter(data: unknown): data is { name: string; description?: string } { + return ( + isRecord(data) && + typeof data.name === "string" && + (data.description === undefined || typeof data.description === "string") + ) +} + +export class InvalidError extends Schema.TaggedErrorClass()("SkillInvalidError", { + path: Schema.String, + message: Schema.optional(Schema.String), + issues: Schema.optional(Schema.Array(Issue)), +}) {} + +export class NameMismatchError extends Schema.TaggedErrorClass()("SkillNameMismatchError", { + path: Schema.String, + expected: Schema.String, + actual: Schema.String, +}) {} + +export class NotFoundError extends Schema.TaggedErrorClass()("Skill.NotFoundError", { + name: Schema.String, + available: Schema.Array(Schema.String), +}) { + override get message() { + return `Skill "${this.name}" not found. Available skills: ${this.available.join(", ") || "none"}` + } +} + +type State = { + skills: Record + dirs: Set +} + +type DiscoveryState = { + matches: string[] + dirs: string[] +} + +type ScanState = { + matches: Set + dirs: Set +} + +export interface Interface { + readonly get: (name: string) => Effect.Effect + readonly require: (name: string) => Effect.Effect + readonly all: () => Effect.Effect + readonly dirs: () => Effect.Effect + readonly available: (agent?: Agent.Info) => Effect.Effect +} + +const add = Effect.fnUntraced(function* (state: State, match: string, events: EventV2Bridge.Service["Service"]) { + const md = yield* Effect.tryPromise({ + try: () => ConfigMarkdown.parse(match), + catch: (err) => err, + }).pipe( + Effect.catch( + Effect.fnUntraced(function* (err) { + const message = FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse skill ${match}` + const { Session } = yield* Effect.promise(() => import("@/session/session")) + yield* events.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + yield* Effect.logError("failed to load skill", { skill: match, error: err }) + return undefined + }), + ), + ) + + if (!md) return + + if (!isSkillFrontmatter(md.data)) return + + if (state.skills[md.data.name]) { + yield* Effect.logWarning("duplicate skill name", { + name: md.data.name, + existing: state.skills[md.data.name].location, + duplicate: match, + }) + } + + state.dirs.add(path.dirname(match)) + state.skills[md.data.name] = { + name: md.data.name, + description: md.data.description, + location: match, + content: md.content, + } +}) + +const scan = Effect.fnUntraced(function* ( + state: ScanState, + root: string, + pattern: string, + opts?: { dot?: boolean; scope?: string }, +) { + const matches = yield* Effect.tryPromise({ + try: () => + Glob.scan(pattern, { + cwd: root, + absolute: true, + include: "file", + symlink: true, + dot: opts?.dot, + }), + catch: (error) => error, + }).pipe( + Effect.catch((error) => { + if (!opts?.scope) return Effect.die(error) + return Effect.logError(`failed to scan ${opts.scope} skills`, { dir: root, error: error }).pipe( + Effect.as([] as string[]), + ) + }), + ) + + for (const match of matches) { + state.matches.add(match) + state.dirs.add(path.dirname(match)) + } +}) + +const discoverSkills = Effect.fnUntraced(function* ( + config: Config.Interface, + discovery: Discovery.Interface, + fsys: FSUtil.Interface, + global: Global.Interface, + disableExternalSkills: boolean, + disableClaudeCodeSkills: boolean, + directory: string, + worktree: string, +) { + const state: ScanState = { matches: new Set(), dirs: new Set() } + + const externalDirs: string[] = [] + if (!disableExternalSkills) { + if (!disableClaudeCodeSkills) externalDirs.push(CLAUDE_EXTERNAL_DIR) + externalDirs.push(AGENTS_EXTERNAL_DIR) + + for (const dir of externalDirs) { + const root = path.join(global.home, dir) + if (!(yield* fsys.isDir(root))) continue + yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" }) + } + + const upDirs = yield* fsys + .up({ targets: externalDirs, start: directory, stop: worktree }) + .pipe(Effect.catch(() => Effect.succeed([] as string[]))) + + for (const root of upDirs) { + yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" }) + } + } + + const configDirs = yield* config.directories() + for (const dir of configDirs) { + yield* scan(state, dir, OPENCODE_SKILL_PATTERN) + } + + const cfg = yield* config.get() + for (const item of cfg.skills?.paths ?? []) { + const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item + const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded) + if (!(yield* fsys.isDir(dir))) { + yield* Effect.logWarning("skill path not found", { path: dir }) + continue + } + + yield* scan(state, dir, SKILL_PATTERN) + } + + for (const url of cfg.skills?.urls ?? []) { + const pulledDirs = yield* discovery.pull(url) + for (const dir of pulledDirs) { + yield* scan(state, dir, SKILL_PATTERN) + } + } + + return { + matches: Array.from(state.matches), + dirs: Array.from(state.dirs), + } +}) + +const loadSkills = Effect.fnUntraced(function* ( + state: State, + discovered: DiscoveryState, + events: EventV2Bridge.Service["Service"], +) { + yield* Effect.forEach(discovered.matches, (match) => add(state, match, events), { + concurrency: "unbounded", + discard: true, + }) + + yield* Effect.logInfo("init", { count: Object.keys(state.skills).length }) +}) + +export class Service extends Context.Service()("@opencode/Skill") {} + +// Boot-frozen by design (#1740): the v1/TUI skill tree loads once per +// directory at instance startup (InstanceState below) and is never re-read, so +// a merged SKILL.md edit is invisible to a running v1 session until restart. +// Live watcher-driven invalidation ships in the V2 session core only +// (core/src/skill.ts) — this path stays boot-frozen deliberately, and must +// not claim parity with it. +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const discovery = yield* Discovery.Service + const config = yield* Config.Service + const events = yield* EventV2Bridge.Service + const fsys = yield* FSUtil.Service + const global = yield* Global.Service + const flags = yield* RuntimeFlags.Service + const discovered = yield* InstanceState.make( + Effect.fn("Skill.discovery")(function* (ctx) { + return yield* discoverSkills( + config, + discovery, + fsys, + global, + flags.disableExternalSkills, + flags.disableClaudeCodeSkills, + ctx.directory, + ctx.worktree, + ) + }), + ) + const state = yield* InstanceState.make( + Effect.fn("Skill.state")(function* () { + const s: State = { skills: {}, dirs: new Set() } + // Register the built-in skill BEFORE disk discovery so a user-disk + // skill with the same name can override it. + s.skills[CUSTOMIZE_OPENCODE_SKILL_NAME] = { + name: CUSTOMIZE_OPENCODE_SKILL_NAME, + description: CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION, + location: "", + content: CUSTOMIZE_OPENCODE_SKILL_BODY, + } + yield* loadSkills(s, yield* InstanceState.get(discovered), events) + return s + }), + ) + + const get = Effect.fn("Skill.get")(function* (name: string) { + const s = yield* InstanceState.get(state) + return s.skills[name] + }) + + const require = Effect.fn("Skill.require")(function* (name: string) { + const s = yield* InstanceState.get(state) + const info = s.skills[name] + if (info) return info + return yield* new NotFoundError({ name, available: Object.keys(s.skills).toSorted() }) + }) + + const all = Effect.fn("Skill.all")(function* () { + const s = yield* InstanceState.get(state) + return Object.values(s.skills) + }) + + const dirs = Effect.fn("Skill.dirs")(function* () { + return (yield* InstanceState.get(discovered)).dirs + }) + + const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) { + const s = yield* InstanceState.get(state) + const list = Object.values(s.skills).toSorted((a, b) => a.name.localeCompare(b.name)) + if (!agent) return list + return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny") + }) + + return Service.of({ get, require, all, dirs, available }) + }), +) + +export function fmt(list: Info[], opts: { verbose: boolean }) { + const described = list.filter((skill) => skill.description !== undefined) + if (described.length === 0) return "No skills are currently available." + if (opts.verbose) { + return [ + "", + ...described + .toSorted((a, b) => a.name.localeCompare(b.name)) + .flatMap((skill) => [ + " ", + ` ${skill.name}`, + ` ${skill.description}`, + ` ${escapeHtml(skill.location)}`, + " ", + ]), + "", + ].join("\n") + } + + return [ + "## Available Skills", + ...described + .toSorted((a, b) => a.name.localeCompare(b.name)) + .map((skill) => `- **${skill.name}**: ${skill.description}`), + ].join("\n") +} + +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Discovery.node, Config.node, EventV2Bridge.node, FSUtil.node, Global.node, RuntimeFlags.node], +}) + +export * as Skill from "." From 0680e635ceab95786cfe712020baec262046f396 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Fri, 23 Oct 2026 08:03:24 -0400 Subject: [PATCH 4/4] test(core): poll instead of fixed sleeps for skill invalidation (#1740) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The debounced flush lands asynchronously; poll the observable outcome with a 5s bound instead of fixed 200ms sleeps so the tests stay green under a loaded shared bun process. The reload is lazy, so the polls drive list() — a fixed sleep never observes the re-glob on its own. --- packages/app-bundle/manifest.json | 4 +- .../overlay/packages/core/test/skill.test.ts | 65 +++++++++++++++---- 2 files changed, 54 insertions(+), 15 deletions(-) diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 287a950f..d2d90368 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -51,7 +51,7 @@ "packages/core/test/session-prompt.test.ts": "2af05be037473266c7b38721abbf7f865a4dfe35c9e779282cb7f697ba0a2adc", "packages/core/test/session-runner-recorded.test.ts": "962301989a4a315379819bc3d4600d56c3e646b1a9390d02d01556578fc66adc", "packages/core/test/session-runner.test.ts": "4cf0d4e3989a32b0c693473d4f94bf787bd964e79b312a7ee0032305503edb20", - "packages/core/test/skill.test.ts": "10b7f352750c496d24dcff66804fbe127d2f6aa9456edd647d5406231e9b0a61", + "packages/core/test/skill.test.ts": "341bfbdc9b6a18b32f8370d0f13381bcec0c54ecf336465fe3b6b48b55fcfaad", "packages/core/test/tool-question.test.ts": "4df41393f15104a1d3a9c3d06e30cef515108490b41fcf4d9a7d9399fd4ed945", "packages/core/test/tool-skill.test.ts": "84bb1900c7e44d03d1a1e3397e069b4df1251fb6a0c77e6384106e20f9aecfc9", "packages/llm/src/cache-policy.ts": "04c1fb5d3d061cfa95d04e54df0a1c2cdaafac921224242446eaa2968bff7857", @@ -1016,7 +1016,7 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-10-23T11:53:43.831Z", + "extracted_at": "2026-10-23T12:02:51.857Z", "per_package": { "packages/app": { "A": 254, diff --git a/packages/app-bundle/overlay/packages/core/test/skill.test.ts b/packages/app-bundle/overlay/packages/core/test/skill.test.ts index fdb352df..fab7471b 100644 --- a/packages/app-bundle/overlay/packages/core/test/skill.test.ts +++ b/packages/app-bundle/overlay/packages/core/test/skill.test.ts @@ -66,6 +66,18 @@ const publish = (file: string, event: "add" | "change" | "unlink") => Effect.asVoid, ) +// Bounded poll until the debounced invalidation flush is observable in list(). +const pollNames = (skills: SkillV2.Interface, expected: readonly string[]) => + Effect.gen(function* () { + const deadline = Date.now() + 5_000 + let names = (yield* skills.list()).map((skill) => skill.name) + while (JSON.stringify(names) !== JSON.stringify(expected) && Date.now() < deadline) { + yield* Effect.sleep("50 millis") + names = (yield* skills.list()).map((skill) => skill.name) + } + return names + }) + const withTmp = (use: (tmp: { path: string }) => Effect.Effect) => Effect.acquireRelease(Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]())).pipe( Effect.flatMap(use), @@ -93,9 +105,14 @@ describe("SkillV2 cache", () => { yield* rewrite(root, "my-skill", "new description", "new body") yield* publish(path.join(root, "my-skill", "SKILL.md"), "change") - yield* Effect.sleep("200 millis") - const after = skillsOf(yield* skills.list()) + // Bounded poll: the debounced flush lands shortly after the publish. + const deadline = Date.now() + 5_000 + let after = skillsOf(yield* skills.list()) + while (after["my-skill"]?.description !== "new description" && Date.now() < deadline) { + yield* Effect.sleep("50 millis") + after = skillsOf(yield* skills.list()) + } expect(after["my-skill"]?.description).toBe("new description") expect(after["my-skill"]?.content).toContain("new body") }), @@ -133,13 +150,13 @@ describe("SkillV2 cache", () => { yield* skillFile(root, "added-skill", "added later", "added body") yield* publish(path.join(root, "added-skill", "SKILL.md"), "add") - yield* Effect.sleep("200 millis") - expect((yield* skills.list()).map((skill) => skill.name)).toEqual(["added-skill", "my-skill"]) + const added = yield* pollNames(skills, ["added-skill", "my-skill"]) + expect(added).toEqual(["added-skill", "my-skill"]) yield* Effect.promise(() => fs.rm(path.join(root, "added-skill"), { recursive: true, force: true })) yield* publish(path.join(root, "added-skill", "SKILL.md"), "unlink") - yield* Effect.sleep("200 millis") - expect((yield* skills.list()).map((skill) => skill.name)).toEqual(["my-skill"]) + const removed = yield* pollNames(skills, ["my-skill"]) + expect(removed).toEqual(["my-skill"]) }), ), ) @@ -205,10 +222,16 @@ describe("SkillV2 invalidation consumer", () => { yield* publish(file, "change") yield* publish(file, "change") yield* publish(path.join(root, "other-skill", "SKILL.md"), "change") - yield* Effect.sleep("200 millis") - expect((yield* skills.list()).length).toBe(2) - // One burst → one debounced flush → one re-glob of the source. + // One burst → one debounced flush → one re-glob of the source. The + // reload is lazy, so the poll must drive list() itself. + const deadline = Date.now() + 5_000 + let listed = yield* skills.list() + while (globs.count < 2 && Date.now() < deadline) { + yield* Effect.sleep("50 millis") + listed = yield* skills.list() + } + expect(listed.length).toBe(2) expect(globs.count).toBe(2) }), ).pipe(Effect.provide(layer), Effect.scoped) @@ -325,9 +348,18 @@ describe("SkillGuidance epoch delivery", () => { yield* rewrite(root, "my-skill", "new description", "new body") yield* publish(path.join(root, "my-skill", "SKILL.md"), "change") - yield* Effect.sleep("200 millis") - const result = yield* SystemContext.reconcile(yield* guidance.load(selection), initialized.snapshot) + // Bounded poll: reconcile is safe to retry while the debounced flush + // has not landed yet (it reads Unchanged until the skills change). + const deadline = Date.now() + 5_000 + let result: SystemContext.ReconcileResult = yield* SystemContext.reconcile( + yield* guidance.load(selection), + initialized.snapshot, + ) + while (result._tag !== "Updated" && Date.now() < deadline) { + yield* Effect.sleep("50 millis") + result = yield* SystemContext.reconcile(yield* guidance.load(selection), initialized.snapshot) + } expect(result).toMatchObject({ _tag: "Updated" }) if (result._tag === "Updated") { expect(result.text).toContain("The available skills have changed") @@ -386,9 +418,16 @@ describe("SkillGuidance epoch delivery", () => { yield* rewrite(root, "my-skill", "new description", "new body") yield* publish(path.join(root, "my-skill", "SKILL.md"), "change") - yield* Effect.sleep("200 millis") - const second = yield* SessionContextEpoch.prepare(db, events, context, sessionID) + // Bounded poll: prepare is safe to retry while the debounced flush has + // not landed (it reads Unchanged and publishes nothing). + const prepareDeadline = Date.now() + 5_000 + let second = yield* SessionContextEpoch.prepare(db, events, context, sessionID) + while (updates.length === 0 && Date.now() < prepareDeadline) { + yield* Effect.sleep("50 millis") + second = yield* SessionContextEpoch.prepare(db, events, context, sessionID) + } + // The baseline is not rewritten — the change arrives as a separate // chronological ContextUpdated event, exactly as a session sees it. expect(second.baseline).toBe(first.baseline)