From 6e196ff01d2112dadba448ae719feea3215f644f Mon Sep 17 00:00:00 2001 From: Brent Yates Date: Sat, 10 Oct 2026 00:55:59 +0000 Subject: [PATCH 1/5] chore(opencode): plan skills.exclude for hidden and trash skill copies Skill discovery scans ~/.claude/skills and ~/.agents/skills with dot: true, so gstack's per-host copies (gstack/.openclaw, .factory, ...) and skills/.trash load next to the canonical skills: about 60 of 86 listed skills, roughly 53k chars of every first request (swxtchio/swx-opencode#59). Only whole-root env flags exist today, and skills.paths only adds roots. Plan: - core: Glob.Options gains an optional ignore list passed to glob's ignore option. It prunes excluded directories during the walk, so a .trash tree is not read at all. - core v1 config: ConfigSkillsV1 gains skills.exclude (string array), described in the schema. The v2 array form of skills lowers to {paths, urls} and cannot carry it, so the object form is the key. - opencode skill: ScanState carries the configured excludes, so every scan honors them: global ~/.claude and ~/.agents, project ancestors, config directories, skills.paths and skills.urls. Patterns match each SKILL.md path relative to the root it was found under, so one list reads the same in every root (for example **/.trash/**). - An excluded match is dropped before it reaches dirs or add(), so it cannot shadow the canonical skill or raise a duplicate-name warning. - Without the setting the default list is empty and nothing changes. - Test (skill.test.ts): a home root with a canonical review skill, a gstack/.openclaw copy and a .trash copy, all named review. With the setting, dirs() holds only the canonical directory and all() only its location. Without it, dirs() holds all three directories. Red proof: the new cases run against the unchanged tree before implementing. - Docs: a short skills.exclude section in packages/web skills.mdx. - Not changed: the v2 skill plugin in packages/core never scans ~/.claude or ~/.agents, so it has no copies to exclude. - Deferred to publication: CHANGESET.md needs this PR's number under "Fork changes", and CI reads it from the PR head, so that entry is added once the PR exists. Claude-Session: https://claude.ai/code/session_015hTsruVJZ4rc4nVuPj1KgU From 0091084047db016bd8e85d9ba5f75b78aa3f3d9d Mon Sep 17 00:00:00 2001 From: Brent Yates Date: Sat, 10 Oct 2026 00:59:43 +0000 Subject: [PATCH 2/5] test(opencode): cover skills.exclude for hidden and trash copies A project root holds a canonical review skill, a gstack/.openclaw copy and a .trash copy, all named review. With skills.exclude set to **/.trash/** and **/gstack/.*/**, dirs() must hold only the canonical directory and all() only its location. Without the setting, dirs() must hold all three directories. Red on the base before the implementation commit: the excluded case fails because dirs() still holds the .trash and gstack/.openclaw directories, and the unset case passes as a preservation check. Claude-Session: https://claude.ai/code/session_015hTsruVJZ4rc4nVuPj1KgU --- packages/opencode/test/skill/skill.test.ts | 54 ++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index f365b63f0d72..130e451050f4 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -49,6 +49,24 @@ This skill is loaded from the global home directory. ) } +const reviewSkill = `--- +name: review +description: A review skill, also copied into a hidden per-host folder and the trash. +--- + +# Review +` + +// The canonical review skill, plus the copies gstack leaves behind: a hidden per-host +// install and a trash folder. All three share one skill name. +async function createReviewCopies(skillsRoot: string) { + await Promise.all( + ["gstack/review", "gstack/.openclaw/review", ".trash/review"].map((dir) => + Bun.write(path.join(skillsRoot, dir, "SKILL.md"), reviewSkill), + ), + ) +} + const withHome = (home: string, self: Effect.Effect) => Effect.acquireUseRelease( Effect.sync(() => { @@ -582,4 +600,40 @@ description: A skill in the .opencode/skills directory. { git: true }, ), ) + + it.live("loads only the canonical skill when skills.exclude matches its hidden and trash copies", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const skillsRoot = path.join(dir, ".claude", "skills") + yield* Effect.promise(() => createReviewCopies(skillsRoot)) + + const skill = yield* Skill.Service + expect(yield* skill.dirs()).toEqual([path.join(skillsRoot, "gstack", "review")]) + const list = (yield* skill.all()).filter((s) => s.location !== "") + expect(list.map((s) => s.location)).toEqual([path.join(skillsRoot, "gstack", "review", "SKILL.md")]) + }), + { git: true, config: { skills: { exclude: ["**/.trash/**", "**/gstack/.*/**"] } } }, + ), + ) + + it.live("loads hidden and trash copies when skills.exclude is not set", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const skillsRoot = path.join(dir, ".claude", "skills") + yield* Effect.promise(() => createReviewCopies(skillsRoot)) + + const skill = yield* Skill.Service + expect((yield* skill.dirs()).toSorted()).toEqual( + [ + path.join(skillsRoot, "gstack", "review"), + path.join(skillsRoot, "gstack", ".openclaw", "review"), + path.join(skillsRoot, ".trash", "review"), + ].toSorted(), + ) + }), + { git: true }, + ), + ) }) From d049944af5564b4b569292c88467f19831e77e4c Mon Sep 17 00:00:00 2001 From: Brent Yates Date: Sat, 10 Oct 2026 00:59:47 +0000 Subject: [PATCH 3/5] feat(opencode): add skills.exclude for hidden and trash copies skills.exclude takes glob patterns for SKILL.md files. Each pattern is matched against the path relative to the root a scan started from, and every scan honors the list: global ~/.claude and ~/.agents, project ancestors, config directories, skills.paths and skills.urls. The list goes to glob's ignore option, so excluded directories are pruned during the walk, and an excluded match never reaches dirs or add(). A copy under gstack/.openclaw or .trash therefore cannot shadow the canonical skill or raise a duplicate-name warning. The default is an empty list, so nothing changes without the setting. The regression fixture lives in a project .claude/skills root rather than a home root that the plan named: the global Global.home is captured when the test layer builds, so withHome cannot redirect it (filed as swxtchio/swx-opencode#157). Refs swxtchio/swx-opencode#59 Claude-Session: https://claude.ai/code/session_015hTsruVJZ4rc4nVuPj1KgU --- packages/core/src/util/glob.ts | 2 ++ packages/core/src/v1/config/skills.ts | 4 ++++ packages/opencode/src/skill/index.ts | 7 +++++-- packages/web/src/content/docs/skills.mdx | 12 ++++++++++++ 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/core/src/util/glob.ts b/packages/core/src/util/glob.ts index febf062daa47..cb691b58fdb9 100644 --- a/packages/core/src/util/glob.ts +++ b/packages/core/src/util/glob.ts @@ -8,6 +8,7 @@ export namespace Glob { include?: "file" | "all" dot?: boolean symlink?: boolean + ignore?: string[] } function toGlobOptions(options: Options): GlobOptions { @@ -17,6 +18,7 @@ export namespace Glob { dot: options.dot, follow: options.symlink ?? false, nodir: options.include !== "all", + ignore: options.ignore, } } diff --git a/packages/core/src/v1/config/skills.ts b/packages/core/src/v1/config/skills.ts index 9879634b4720..4cfaca66be12 100644 --- a/packages/core/src/v1/config/skills.ts +++ b/packages/core/src/v1/config/skills.ts @@ -9,5 +9,9 @@ export const Info = Schema.Struct({ urls: Schema.optional(Schema.Array(Schema.String)).annotate({ description: "URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)", }), + exclude: Schema.optional(Schema.Array(Schema.String)).annotate({ + description: + "Glob patterns of SKILL.md files to skip in every skill root, matched relative to that root (e.g., **/.trash/**)", + }), }) export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 5a04ec213994..653ace55249c 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -92,6 +92,8 @@ type DiscoveryState = { type ScanState = { matches: Set dirs: Set + // skills.exclude globs, matched relative to the root each scan starts from. + exclude: string[] } export interface Interface { @@ -153,6 +155,7 @@ const scan = Effect.fnUntraced(function* ( include: "file", symlink: true, dot: opts?.dot, + ignore: state.exclude, }), catch: (error) => error, }).pipe( @@ -180,7 +183,8 @@ const discoverSkills = Effect.fnUntraced(function* ( directory: string, worktree: string, ) { - const state: ScanState = { matches: new Set(), dirs: new Set() } + const cfg = yield* config.get() + const state: ScanState = { matches: new Set(), dirs: new Set(), exclude: cfg.skills?.exclude ?? [] } const externalDirs: string[] = [] if (!disableExternalSkills) { @@ -207,7 +211,6 @@ const discoverSkills = Effect.fnUntraced(function* ( 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) diff --git a/packages/web/src/content/docs/skills.mdx b/packages/web/src/content/docs/skills.mdx index 2ce88ea5682f..50fd347e4214 100644 --- a/packages/web/src/content/docs/skills.mdx +++ b/packages/web/src/content/docs/skills.mdx @@ -29,6 +29,18 @@ It loads any matching `skills/*/SKILL.md` in `.opencode/` and any matching `.cla Global definitions are also loaded from `~/.config/opencode/skills/*/SKILL.md`, `~/.claude/skills/*/SKILL.md`, and `~/.agents/skills/*/SKILL.md`. +To skip copies you do not want loaded, such as a hidden per-host install or a trash folder, set glob patterns under `skills.exclude`: + +```json +{ + "skills": { + "exclude": ["**/.trash/**", "**/gstack/.*/**"] + } +} +``` + +Each pattern is matched against the `SKILL.md` path relative to the folder the scan started from, such as `.claude` or `.opencode`, so one pattern works in every location above. Excluded skills are never loaded, so they cannot shadow a skill with the same name. + --- ## Write frontmatter From c0f9e910130cd4bcaf653f116d3b1ee9796d7dff Mon Sep 17 00:00:00 2001 From: Brent Yates Date: Sat, 10 Oct 2026 01:05:43 +0000 Subject: [PATCH 4/5] chore(sdk): add skills.exclude to the OpenAPI document and v2 types Only the skills.exclude hunks are taken from the generator output, in packages/sdk/openapi.json and packages/sdk/js/src/v2/gen/types.gen.ts. packages/sdk/openapi.json was already behind the source for activeAssistantMessageID (packages/opencode/src/server/routes/instance/ httpapi/public.ts) before this change. That drift is not regenerated here so this commit stays scoped to skills.exclude. Claude-Session: https://claude.ai/code/session_015hTsruVJZ4rc4nVuPj1KgU --- packages/sdk/js/src/v2/gen/types.gen.ts | 1 + packages/sdk/openapi.json | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 92c67c012acf..f2f4fef0b0a5 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2012,6 +2012,7 @@ export type Config = { skills?: { paths?: Array urls?: Array + exclude?: Array } references?: { [key: string]: string | ConfigV2ReferenceGit | ConfigV2ReferenceLocal diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 94ae1bfa77ac..e75b20e71ce5 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -21938,6 +21938,12 @@ "items": { "type": "string" } + }, + "exclude": { + "type": "array", + "items": { + "type": "string" + } } }, "additionalProperties": false From 394ec5579e9d6a7b466052e76d415aab2c087ce3 Mon Sep 17 00:00:00 2001 From: Brent Yates Date: Sat, 10 Oct 2026 01:11:01 +0000 Subject: [PATCH 5/5] docs(changeset): record #159 under fork features Adds the Features entry that the changeset check requires for this PR. The PR number is only known once the PR exists, so the entry follows the PR creation. Claude-Session: https://claude.ai/code/session_015hTsruVJZ4rc4nVuPj1KgU --- CHANGESET.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGESET.md b/CHANGESET.md index e45dfd8b8f21..539396c78373 100644 --- a/CHANGESET.md +++ b/CHANGESET.md @@ -159,6 +159,7 @@ Newest first. Each entry names the upstream range brought in. ### Features +- **#159** Add `skills.exclude`, glob patterns for `SKILL.md` files that skip copies in every skill root, such as hidden per-host installs and `.trash` folders. Patterns match relative to each root, and excluded skills are not loaded, so they cannot shadow the canonical skill of the same name (swxtchio/swx-opencode#59). _Upstreamable._ - **#73** V1 server prompt queue core (swxtchio/swx-opencode#68): every V1 prompt except `noReply` is admitted to the durable `session_prompt_queue` table before it becomes a user message. `prompt`, `prompt_async` and `command` take `delivery: "steer" | "queue"`; the default steer reaches the next step as before, while `queue` waits until the run would go idle and then runs as its own turn, one per boundary in admission order. Steers wait behind a pending compaction; an abort or error parks pending prompts until the next admission or wake; `/session/:id/queue` lists, withdraws (404 `QueueItemNotPending` once delivered), restores and re-delivers them, with `session.queue.updated` carrying the full list. A sync prompt answers with the final reply of the turn its prompt joined, recorded for it where the loop ends that turn: steers share the reply of the turn they steered, and a queued prompt gets its own turn's; an editor's withdraw that wins answers it with 409 `PromptWithdrawn`. Deliberately, a compaction whose summary turn stopped on an abort or error is not retried, where upstream V1 retried it on the next prompt with that prompt as its parent (summarising it instead of answering it); if the context is still too large, the next overflow check starts a new compaction. Also closes the V1 lost wakeup where a prompt admitted as a run finished joined it and was never answered. Known ordering-only residuals: a `noReply` prompt and the synthetic message a subtask writes to have its output summarised are stored directly rather than through the queue, so they can land out of admission order relative to pending items. _Fork-only; the lost-wakeup fix is upstreamable._ - **#64** Show each configured router member's request count and share so far in `llmrouter/auto` labels in the TUI and run CLI. (swxtchio/swx-opencode#60). _Fork-only._ - **#58** Price each routed step at its configured serving model and show per-model cost shares in `opencode stats`; `db export-usage` exposes per-served-model token/cost splits and derives record-level tokens, costs and summary `reportedCostTotal` from step-finish parts when present, falling back to message totals under the requested model for legacy rows. (swxtchio/swx-opencode#54). _Fork-only._