[Feature request] Expose source on skill.list + allow listing by cwd (workspace scope)
#1427
royenheart
started this conversation in
Ideas
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Background
I'm writing a "skills manager" plugin for dsh that enables/disables skills across three scopes — session > workspace > global. On the host side the plugin enforces "disabled" via a rank-50 skill provider; on the client side it reuses the existing
skill.listRPC rather than adding a custom listing.While building the scope filtering I hit a wall. To render the right set per panel —
user-dsh/user-agents)project-dsh/project-agents)— the client must be able to tell a user-level skill from a project-level skill / global-level skill. Today it cannot:
skill.listis session-addressed (it returns the session's project + user skills merged) and its wire rows carry nosource.Current state (why
sourceis not on the wire)This omission is deliberate, not accidental.
SkillEntry's comment currently reads:/** Skill catalog row (wire projection of the host SkillSummary; provider/source vocabulary stays host-side). */and the skill subsystem docs (
docs/subsystems/skills.md) state the same principle for the model-facing catalog:That decision is reasonable for its original purpose:
<available_skills>reminder and the/-trigger picker only needname+descriptionto decide whether to load a skill; they don't need its origin.path/providercould expose local directories or provider identity (a privacy/trust boundary), and thesourcevocabulary is open (see below), so pinning it as a stable wire contract would couple consumers to every new source.The existing client simply never needed the distinction; a scope-aware manager plugin does.
Proposal
Expose
sourceonSkillEntry— a coarse, open string label (not a path, not a provider identity) — so feature plugins can distinguish globally-available skills from workspace-scoped ones without a second listing RPC. A companion change letsskill.listbe addressed bycwd(instead of only an attached session), so a workspace-scoped panel can list that workspace's skills.Why relaxing
sourceis safe:SkillSourceis itself an open string label, not a path, not a provider identity, and carries no sensitive information:(string & {})keeps it forward-compatible: a new source never breaks the wire contract; a client just sees a literal it doesn't recognize.project-agentsis a category name, not the concrete<project>/.agents/skillspath).Appendix: full implementation patch
packages/host/apiproxy/src/api/skills.ts:packages/host/apiproxy/src/api/skills.schema.ts:export const skillEntrySchema = z.object({ name: z.string().min(1), description: z.string(), whenToUse: z.string().optional(), modelInvocable: z.boolean(), + source: z.string().min(1), }) satisfies z.ZodType<Wire<SkillEntry>>packages/host/apiproxy/src/api-proxy.ts:skills: skills.map(skill => ({ name: skill.name, description: skill.description, ...skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse }, modelInvocable: skill.invocation.modelInvocable, + source: skill.source, })),(Since
sourcebecomes required, the two test fixtures that hand-construct this wire type must add the field too:packages/client/connection/src/client/fixture.tsandpackages/host/apiproxy/tests/fetch-carrier.spec.ts.)Appendix: full implementation patch (II) —
skill.listsupports listing bycwdThe workspace scope has no usable "attached" session:
skill.list's session-addressed path requiresctx.sessions.get()to hit a session already loaded in memory, whileWorkspaceView.sessionIdsis a persisted account that may contain sessions left over from a previous run and not currently attached. More importantly, the web profile sets the base hostskill-filesystemrowdisabled: true(packages/bundle/web-app/cordis.patch.yml, whose comment says "presets own local discovery"), so skill discovery moved to each preset's own scope layer — thus simply callingskillRegistry.list()withcwdandscope: undefinedcannot reach the filesystem provider and yields an empty catalog.Minimal and general solution: relax the request from
{ sessionId }to{ sessionId?; cwd? }, and in thecwdbranch resolve the default preset's standing key as the scope:packages/host/apiproxy/src/api/skills.ts:export interface SkillsApi { - /** Lists the user-invocable skill catalog for the session's project. */ - list(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ skills: readonly SkillEntry[] }>> + /** Lists the user-invocable skill catalog by sessionId (resolves its cwd) or by cwd. */ + list(request: RpcRequest<{ sessionId?: SessionId; cwd?: string }>): Promise<RpcResponse<{ skills: readonly SkillEntry[] }>> }packages/host/apiproxy/src/api/skills.schema.ts:export const skillListRequestSchema = z.object({ - sessionId: sessionIdSchema, + sessionId: sessionIdSchema.optional(), + cwd: z.string().min(1).optional(), }) satisfies z.ZodType<Wire<RequestPayload<'skill.list'>>>packages/host/apiproxy/src/api-proxy.ts(key handler branch):async list(request) { - const { sessionId } = request.payload - const session = ctx.sessions.get(sessionId) - if (session === undefined) return err(request, { code: 'session-not-found', ... }) - ... - const cwd = session.header.cwd + const { sessionId, cwd: directCwd } = request.payload + const presets = ctx.get('agentPresets') + let cwd: string + let scope: ScopeKey | undefined + if (directCwd !== undefined) { + cwd = directCwd + scope = undefined + if (presets !== undefined) { + try { + // Workspace listing has no session; use the default preset's + // standing key so its per-scope skill providers (filesystem) resolve. + scope = await presets.standingKeyFor() + } catch { scope = undefined } + } + } else if (sessionId !== undefined) { + // ... existing session resolution + presenterScopeFor ... + } else { + return err(request, { code: 'bad-request', message: 'skill.list requires sessionId or cwd', ... }) + } + ... }(
presets.standingKeyFor()without an id returns the standing scope ofdefaultId; seepackages/preset/agent-presets/src/index.ts.)Appendix: two robustness and enforcement notes
A. A provider with an empty
descriptiondrags down the whole session (suggestion: default to disable + warn, instead of throw)ctx.skills.registerProvider'slistcandidates enforcedescription.length > 0invalidateCandidate, otherwise it throws (skill provider "…" returned skill "…" without a description), and that throw bubbles up to the agent request, breaking the whole conversation.An enforcement provider for "disabled skills" (like this plugin) naturally returns a placeholder candidate for blocked skills — if the placeholder description is empty it triggers the crash above. A plugin can work around it by filling a non-empty description (I do), but it's more robust for the registry to degrade to "disabled" and warn for a candidate with no description rather than throw, so a typo in any third-party provider can't drag down the session.
B. A global enforcement provider is shadowed by the per-preset filesystem (web profile)
The web profile sets the base host
skill-filesystemrowdisabled: true(packages/bundle/web-app/cordis.patch.yml), with discovery moved to each preset's own scope layer. And thectx.skillsregistry merges "global layer + scope chain", where a nearer layer's same-name entry overrides a farther layer's (incollectFresh,merged.set(name, entry)runs per layer in order, and rank only decides within a single layer).So an enforcement provider registered in the global layer (rank 50) is entirely shadowed by
skill-filesystem(rank 100–600) in the preset layer — a disabled skill still appears in the catalog withmodelInvocable: true. For cross-scope disabling plugins this is fatal: to enforce via a provider you'd have to register on the same layer as the filesystem (the preset layer) or a nearer layer.General fix already adopted (make a "hard block" win across layers in
collectFresh, deny wins):for (const layer of layers) { const collected = await this.collectLayer(layer, options) if (!collected.cacheable) cacheable = false - for (const entry of collected.entries) merged.set(entry.candidate.name, entry) + for (const entry of collected.entries) { + const existing = merged.get(entry.candidate.name) + // A fully-disabled candidate is a hard block: a nearer layer must not revive it. + if (existing !== undefined + && existing.candidate.invocation.modelInvocable === false + && existing.candidate.invocation.userInvocable === false) continue + merged.set(entry.candidate.name, entry) + } }That is: treat "fully disabled (
modelInvocable:false && userInvocable:false)" as a deny that a nearer layer must not revive; ordinary same-name replacement (shadow) still follows nearest-layer-wins. This is a first-class registry semantic, independent of any profile or specific plugin (a no-op in base/headless, restoring deny absoluteness in web). Verified locally: after disabling,skill.listno longer returns that skill, andtool-skill'ssnapshot().filter(isModelInvocable)also filters it out of the model catalog.Not implemented, discussion-only alternative: make "fully disabled" an explicit
SkillCandidate.blocked?: true(rather than inferring from the invocation combinationmodelInvocable:false && userInvocable:false). An explicit field is clearer and can't be misread as "user-only invocable (modelInvocable:false, userInvocable:true)", but it adds API surface; for now I use the combination check, keeping the minimal zero-new-API change.Questions to confirm
cwdbranch (already merged intoskill.list) appropriate? Is there a more canonical "get a scope without a session" entry point worth splitting into a separateskill.listByCwd?sourcebe tightened to an explicit enum (dropping the open(string & {})tail) before going on the wire? I lean toward keeping the open string, to avoid breaking the contract with new sources.Related
docs/subsystems/skills.md— the original design description "catalog omits bodies, paths, sources, providers, and routing hints".All reactions