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
37 changes: 37 additions & 0 deletions docs/design/2026-07-20-skills-default-disabled.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Overridable default-disabled skills

## Problem

`skills.disabled` is a case-insensitive union across settings scopes. That makes it a hard denylist: a project cannot enable a skill disabled by user or system settings. This is correct for policy, but it cannot represent a skill that should start off and remain available for project opt-in.

## Settings

Add two case-insensitive union lists while keeping `skills.disabled` unchanged:

| Setting | Meaning |
| ------------------------ | ------------------------------------------------------- |
| `skills.disabled` | Hard disable. Always wins and preserves existing locks. |
| `skills.defaultDisabled` | Disabled unless explicitly enabled. |
| `skills.enabled` | Explicit opt-in; cannot override `skills.disabled`. |

Effective disables are `disabled + (defaultDisabled - enabled)`. An explicit `enabled` list is used instead of replacement semantics so enabling one inherited default does not replace unrelated defaults.

## Runtime and persistence

One CLI-local resolver computes the effective disabled names and whether each disabled skill is `hard` or `default`. Existing runtime consumers continue reading the effective set through `Config.getDisabledSkillNames()`; core skill discovery and execution APIs do not change.

The `/skills` picker and daemon toggle apply the same rules:

- enabling removes a workspace hard disable and adds the canonical name to workspace `skills.enabled` only when needed;
- disabling removes the workspace opt-in and adds the canonical name to workspace `skills.disabled`;
- higher-scope `skills.disabled` entries remain locked;
- unrelated and unavailable skill entries are preserved.

Workspace skill status adds a disable reason and optional lock scope so clients can distinguish a hard lock from an overridable default. The daemon-local and ACP status paths both read the same CLI-local resolver.

## Scope

- No skill is added to `defaultDisabled` by this change.
- `disable-model-invocation` and managed-skill ACP operations are unchanged.
- Existing `skills.disabled` configuration remains compatible.
- Changes are limited to settings, the two existing toggle surfaces, workspace skill status, their wire types, documentation, and focused tests.
14 changes: 7 additions & 7 deletions docs/design/daemon-skill-toggle.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ The response contains the canonical skill name, requested state, whether persist

## Semantics

The API changes only workspace `skills.disabled`. Skill lookup is case-insensitive, but the canonical discovered name is persisted. Updating one target removes target duplicates and case variants without deleting orphan entries for unavailable skills. A second identical request is a no-op.
The API changes workspace `skills.disabled` and `skills.enabled` as needed. Skill lookup is case-insensitive, but the canonical discovered name is persisted. Enabling a default-disabled skill writes an explicit opt-in; disabling it removes the opt-in and writes a hard workspace disable. Updating one target removes target duplicates and case variants without deleting orphan entries for unavailable skills. A second identical request is a no-op.

The route rejects states the CLI panel cannot toggle:

Expand All @@ -28,9 +28,9 @@ The route rejects states the CLI panel cannot toggle:

The scope lock check and workspace read-modify-write happen inside the daemon's per-workspace settings lock. A failed write stops before refresh and event publication.

## `skills.disabled` versus `disable-model-invocation`
## Skill availability versus `disable-model-invocation`

`skills.disabled` is an operator setting merged as a case-insensitive union across scopes. It removes matching skill slash commands and model-visible skill entries, and execution-time validation rejects the skill. The daemon endpoint writes the workspace member of this union.
`skills.disabled` is an operator hard denylist merged as a case-insensitive union across scopes. `skills.defaultDisabled` supplies overridable defaults and `skills.enabled` supplies explicit opt-ins, with `disabled > enabled > defaultDisabled` precedence. Effective disables remove matching skill slash commands and model-visible skill entries, and execution-time validation rejects the skill. The daemon endpoint writes the workspace members of `disabled` and `enabled`.

`disable-model-invocation` is SKILL.md metadata. It hides a skill from model invocation while preserving direct user invocation. The existing managed-skill ACP operation edits that metadata and is intentionally not reused by this API.

Expand All @@ -42,21 +42,21 @@ The scope lock check and workspace read-modify-write happen inside the daemon's
4. If an ACP child is live, invoke `qwen/control/workspace/skills/refresh`.
5. The child reloads workspace-scope settings and refreshes every active session, including busy sessions.
6. Each session reloads its own workspace settings, rebuilds and pushes `available_commands_update`, and notifies SkillManager consumers.
7. Publish the existing workspace `settings_changed` event for `skills.disabled`.
7. Publish the existing workspace `settings_changed` event for each changed skill-settings key.

An in-flight model request cannot be rewritten. Subsequent skill execution checks, command snapshots, and model contexts read the new state.

## Downstream consumers

- Settings merge: system defaults, user, workspace, and system `skills.disabled` form the effective disabled-name set.
- Workspace status: ACP and daemon-local skill mapping expose disabled state and false-only `userInvocable`.
- Settings merge: system defaults, user, workspace, and system lists form the effective disabled-name set with `disabled > enabled > defaultDisabled` precedence.
- Workspace status: ACP and daemon-local skill mapping expose disabled state, disablement reason, lock scope, and false-only `userInvocable`.
- Slash commands: available-command construction removes disabled skills and sends updated command metadata to daemon clients.
- Model context: SkillManager change listeners refresh the Skill tool description and available-skill context.
- Execution validation: the Skill tool re-reads the disabled-name provider before invocation, so later calls are rejected immediately.
- Extension state: inactive extension skills remain non-toggleable even when they are not disabled by settings.
- Daemon cache: the cached live-child skill snapshot is invalidated after persistence so later GET requests cannot replay stale state.
- SDK consumers: both primary-workspace and workspace-qualified clients share the response and error contract.
- Events: existing `settings_changed` consumers observe the committed `skills.disabled` value; there is no new event type.
- Events: existing `settings_changed` consumers observe each committed `skills.disabled` or `skills.enabled` value; there is no new event type.

## Failure behavior

Expand Down
6 changes: 3 additions & 3 deletions docs/developers/qwen-serve-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -2557,9 +2557,9 @@ SSE event (workspace-scoped): `tool_toggled` with `{toolName, enabled, originato

Capability tag: `workspace_skill_toggle`. The workspace-qualified form is `POST /workspaces/:workspace/skills/:name/enable`.

Toggle a loaded, user-invocable skill through the workspace `skills.disabled` list, matching the CLI `/skills` panel's Space-key behavior. Lookup is case-insensitive, while persistence and the response use the skill's canonical name. Existing disabled entries for skills that are no longer loaded are preserved, and duplicate/case-variant entries for the target are collapsed. A disable entry inherited from system defaults, user, or system scope locks the skill: workspace scope cannot override the merged union.
Toggle a loaded, user-invocable skill through the workspace skill settings, matching the CLI `/skills` panel's Space-key behavior. Lookup is case-insensitive, while persistence and the response use the skill's canonical name. Enabling a `skills.defaultDisabled` skill adds a workspace `skills.enabled` opt-in; disabling removes that opt-in and adds a workspace `skills.disabled` entry. Existing entries for skills that are no longer loaded are preserved, and duplicate/case-variant entries for the target are collapsed. A hard-disable entry inherited from system defaults, user, or system scope locks the skill: workspace scope cannot override it.

This is different from the ACP `qwen/skills/setEnabled` managed-skill operation and the `disable-model-invocation` frontmatter field. `skills.disabled` removes the skill from slash-command/model availability and rejects later skill execution. `disable-model-invocation: true` keeps direct user invocation available and only hides the skill from model invocation.
This is different from the ACP `qwen/skills/setEnabled` managed-skill operation and the `disable-model-invocation` frontmatter field. Effective skill availability follows `skills.disabled` > `skills.enabled` > `skills.defaultDisabled`. Both hard and default disables remove the skill from slash-command/model availability and reject later skill execution. `disable-model-invocation: true` keeps direct user invocation available and only hides the skill from model invocation.

Request:

Expand Down Expand Up @@ -2590,7 +2590,7 @@ Errors:
- `404 {code: 'skill_not_found'}` — no loaded skill matches the name.
- `409 {code: 'skill_not_toggleable', reason: 'not_user_invocable' | 'inactive_extension' | 'locked', lockedScope?: 'system' | 'user' | 'systemDefaults'}` — the CLI panel would not allow the target to be toggled. `lockedScope` is present only when `reason` is `locked`.

The mutation reuses the workspace-scoped `settings_changed` event with `key: 'skills.disabled'`; it does not add a new event type.
The mutation reuses the workspace-scoped `settings_changed` event for each changed key (`skills.disabled` and/or `skills.enabled`); it does not add a new event type. Workspace skill status cells include optional `disabledReason: 'hard' | 'default' | 'inactive_extension'` and `lockedScope: 'system' | 'user' | 'systemDefaults'` fields.

#### `POST /workspace/init`

Expand Down
10 changes: 7 additions & 3 deletions docs/users/configuration/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -494,9 +494,13 @@ execute when typed.

Controls which [Skills](../features/skills) are exposed to the model.

| Setting | Type | Description | Default |
| ----------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `skills.disabled` | array of strings | Skill names to hide. Matched case-insensitively against the skill name. Hidden skills do not appear in `<available_skills>` or as `/<name>` slash commands. **Merged as a union** across user/project/system scopes, so a project cannot remove entries defined in user or system settings. | `undefined` |
| Setting | Type | Description | Default |
| ------------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `skills.disabled` | array of strings | Hard-disabled skill names. Matched case-insensitively and **merged as a union** across settings scopes, so project settings cannot override a user or system entry. Hidden skills do not appear in `<available_skills>` or as `/<name>` slash commands. | `undefined` |
| `skills.defaultDisabled` | array of strings | Skill names that start disabled but can be opted into through `skills.enabled`. Matched case-insensitively and merged as a union across settings scopes. | `undefined` |
| `skills.enabled` | array of strings | Explicit opt-ins that override matching `skills.defaultDisabled` entries. Matched case-insensitively and merged as a union across settings scopes. This setting cannot override `skills.disabled`. | `undefined` |

The precedence is `skills.disabled` > `skills.enabled` > `skills.defaultDisabled`. For example, a user can put a skill in `defaultDisabled` and a project can add the same name to `enabled`; a hard `disabled` entry at any scope still wins.

#### mcp

Expand Down
2 changes: 1 addition & 1 deletion docs/users/qwen-serve.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ idle daemon returns `initialized: false` with an empty snapshot. Once a
session is alive they switch to `initialized: true` and surface the real
state.

To mirror the CLI `/skills` panel remotely, call `POST /workspace/skills/:name/enable` with `{ "enabled": true | false }` after checking the `workspace_skill_toggle` capability. The route writes only workspace `skills.disabled`, rejects unknown, hidden, inactive-extension, higher-scope-locked, and untrusted targets, and immediately refreshes active ACP sessions. A `deferred` response means the setting was saved while no ACP child was running; it will apply when the child starts. `skills.disabled` disables both manual and model use, unlike `disable-model-invocation: true`, which keeps direct `/skill-name` invocation available.
To mirror the CLI `/skills` panel remotely, call `POST /workspace/skills/:name/enable` with `{ "enabled": true | false }` after checking the `workspace_skill_toggle` capability. The route updates workspace `skills.disabled` and `skills.enabled` as needed, rejects unknown, hidden, inactive-extension, higher-scope-locked, and untrusted targets, and immediately refreshes active ACP sessions. Enabling a `skills.defaultDisabled` skill writes a canonical opt-in to `skills.enabled`; a hard `skills.disabled` entry inherited from a higher scope still cannot be overridden. Skill status cells expose `disabledReason` (`hard`, `default`, or `inactive_extension`) and an optional `lockedScope`. A `deferred` response means the setting was saved while no ACP child was running; it will apply when the child starts. `skills.disabled` disables both manual and model use, unlike `disable-model-invocation: true`, which keeps direct `/skill-name` invocation available.

`GET /workspace/env` and `GET /workspace/preflight` always answer with
`initialized: true` regardless of ACP state. `env` never consults ACP
Expand Down
2 changes: 2 additions & 0 deletions packages/acp-bridge/src/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,8 @@ export interface ServeWorkspaceSkillStatus extends ServeStatusCell {
description: string;
level: ServeSkillLevel;
modelInvocable: boolean;
disabledReason?: 'hard' | 'default' | 'inactive_extension';
lockedScope?: 'system' | 'user' | 'systemDefaults';
userInvocable?: false;
installedPath?: string;
argumentHint?: string;
Expand Down
22 changes: 19 additions & 3 deletions packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ import {
buildDisabledSkillNamesProvider,
loadCliConfig,
} from '../config/config.js';
import { resolveSkillSettings } from '../config/skill-settings.js';
import {
createWorkspaceMemoryExtractionErrorLogger,
shouldSuppressRememberErrorDetails,
Expand Down Expand Up @@ -5445,7 +5446,22 @@ class QwenAgent implements Agent {
}

try {
const disabled = config.getDisabledSkillNames();
const resolved = resolveSkillSettings(
loadSettings(this.workspaceCwd(config), {
consumeCorruptionEnvVars: false,
skipLoadEnvironment: true,
}),
);
const disablements = new Map(
Array.from(config.getDisabledSkillNames(), (name) => {
const normalizedName = name.trim().toLowerCase();
return [
normalizedName,
resolved.disablements.get(normalizedName) ??
({ reason: 'hard' } as const),
] as const;
}),
);
try {
await config.getExtensionManager().refreshCache();
} catch (error) {
Expand All @@ -5461,7 +5477,7 @@ class QwenAgent implements Agent {
const skillsByKey = new Map(
skills.map((skill) => [
`${skill.level}:${skill.extensionName ?? ''}:${skill.name}`,
mapSkillConfigToStatus(skill, disabled, {
mapSkillConfigToStatus(skill, disablements, {
disabled: isInactiveExtensionSkill(skill, inactiveSkillRefs),
}),
]),
Expand All @@ -5485,7 +5501,7 @@ class QwenAgent implements Agent {
level: 'extension',
extensionName,
},
disabled,
disablements,
{ disabled: true },
),
);
Expand Down
19 changes: 17 additions & 2 deletions packages/cli/src/config/config.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,8 +428,15 @@ describe('Configuration Integration Tests', () => {
describe('buildDisabledSkillNamesProvider', async () => {
const { buildDisabledSkillNamesProvider } = await import('./config.js');

function fakeSettings(disabled: unknown) {
return { merged: { skills: { disabled } } } as never;
function fakeSettings(
disabled: unknown,
defaultDisabled?: unknown,
enabled?: unknown,
) {
return {
merged: { skills: { disabled, defaultDisabled, enabled } },
forScope: () => ({ settings: { skills: {} } }),
} as never;
}

it('returns a normalized set from a normal array', () => {
Expand Down Expand Up @@ -470,4 +477,12 @@ describe('buildDisabledSkillNamesProvider', async () => {
);
expect(provider()).toEqual(new Set(['keep']));
});

it('applies explicit enables between defaults and hard disables', () => {
const provider = buildDisabledSkillNamesProvider(
fakeSettings(['hard'], ['soft', 'hard'], ['SOFT', 'HARD']),
);

expect(provider()).toEqual(new Set(['hard']));
});
});
Loading
Loading