Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGESET.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,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._
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/util/glob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export namespace Glob {
include?: "file" | "all"
dot?: boolean
symlink?: boolean
ignore?: string[]
}

function toGlobOptions(options: Options): GlobOptions {
Expand All @@ -17,6 +18,7 @@ export namespace Glob {
dot: options.dot,
follow: options.symlink ?? false,
nodir: options.include !== "all",
ignore: options.ignore,
}
}

Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/v1/config/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Info>
7 changes: 5 additions & 2 deletions packages/opencode/src/skill/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ type DiscoveryState = {
type ScanState = {
matches: Set<string>
dirs: Set<string>
// skills.exclude globs, matched relative to the root each scan starts from.
exclude: string[]
}

export interface Interface {
Expand Down Expand Up @@ -153,6 +155,7 @@ const scan = Effect.fnUntraced(function* (
include: "file",
symlink: true,
dot: opts?.dot,
ignore: state.exclude,
}),
catch: (error) => error,
}).pipe(
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand Down
54 changes: 54 additions & 0 deletions packages/opencode/test/skill/skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <A, E, R>(home: string, self: Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
Effect.sync(() => {
Expand Down Expand Up @@ -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 !== "<built-in>")
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 },
),
)
})
1 change: 1 addition & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2012,6 +2012,7 @@ export type Config = {
skills?: {
paths?: Array<string>
urls?: Array<string>
exclude?: Array<string>
}
references?: {
[key: string]: string | ConfigV2ReferenceGit | ConfigV2ReferenceLocal
Expand Down
6 changes: 6 additions & 0 deletions packages/sdk/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -21938,6 +21938,12 @@
"items": {
"type": "string"
}
},
"exclude": {
"type": "array",
"items": {
"type": "string"
}
}
},
"additionalProperties": false
Expand Down
12 changes: 12 additions & 0 deletions packages/web/src/content/docs/skills.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading