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
76 changes: 76 additions & 0 deletions docs/design/daemon-skill-batch-toggle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Daemon Skill batch toggle

## Problem

Remote Skill managers can toggle only one Skill per request. Closing several
Skills therefore requires client-side request orchestration and provides no
single response that records all target outcomes.

## API

Add collection-level mutation routes:

- `POST /workspace/skills/enable`
- `POST /workspaces/:workspace/skills/enable`

The request body is:

```json
{
"skillNames": ["review", "deploy", "missing"],
"enabled": false
}
```

`skillNames` is a non-empty string array with at most 100 entries. Names are
trimmed and deduplicated case-insensitively while preserving first-seen order.
The response is best-effort for expected target errors: valid targets are
validated against one status snapshot, persisted in one locked write, and
applied with one live-session refresh. Unknown, hidden, inactive-extension,
and locked targets are returned without blocking the valid targets. Unexpected
persistence and runtime-generation failures fail the whole request.

```json
{
"enabled": false,
"activation": "applied",
"sessionsRefreshed": 2,
"sessionsFailed": 0,
"results": [
{
"skillName": "review",
"enabled": false,
"changed": true
},
{
"skillName": "deploy",
"enabled": false,
"changed": true
}
],
"errors": [
{
"skillName": "missing",
"code": "skill_not_found",
"error": "Skill not found: missing"
}
]
}
```

`results` and `errors` each preserve request order within their own array;
the response does not reconstruct the original mixed ordering, so clients
re-match targets by `skillName`.

Malformed requests still fail as a whole with HTTP 400. Workspace trust,
authentication, client identity, and generation ownership use the same gates
as the single-Skill route.

## Compatibility

Advertise `workspace_skill_batch_toggle` separately from
`workspace_skill_toggle`. Clients must pre-flight the new capability before
calling the collection route. The existing single-Skill route and response
remain unchanged. The collection routes are HTTP-only: the ACP
`_qwen/workspace/skills` dispatch surface stays read-only, matching the
single-Skill toggle.
13 changes: 13 additions & 0 deletions docs/developers/daemon/13-sdk-daemon-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,19 @@ await client

Pre-flight `capabilities.features.includes('workspace_skill_toggle')`. The typed `DaemonSkillToggleResult` reports the canonical `skillName`, whether disk state `changed`, activation state (`applied`, `deferred`, or `partial`), and refreshed/failed session counts. `DaemonWorkspaceSkillStatus.userInvocable` is an optional false-only field; absence means the skill is user-invocable.

For batch changes, pre-flight `workspace_skill_batch_toggle` and call either client shape with the same contract:

```ts
await client.setWorkspaceSkillsEnabled(['review', 'deploy'], false, {
clientId: 'dashboard-1',
});
await client
.workspaceByCwd('/work/secondary')
.setWorkspaceSkillsEnabled(['review', 'deploy'], true);
```

`DaemonSkillBatchToggleResult` contains ordered successful `results`, per-target `errors`, and batch-level activation/session-refresh counts. The daemon persists valid targets together and refreshes active sessions once; one expected target error does not block other valid targets. The method throws only on a non-200 response; a 200 does not mean every target was applied, so always inspect `errors` before treating the batch as successful.

Workspace display names are optional presentation metadata. Pre-flight `capabilities.features.includes('workspace_display_name')`; workspace ids and canonical paths remain the only selectors, and duplicate display names are valid.

```ts
Expand Down
50 changes: 49 additions & 1 deletion docs/developers/qwen-serve-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design
'mcp_server_runtime_mutation',
'workspace_file_read', 'workspace_file_bytes', 'workspace_file_write',
'session_approval_mode_control', 'workspace_tool_toggle', 'workspace_skill_toggle',
'workspace_skill_batch_toggle',
'workspace_settings', 'workspace_init', 'workspace_mcp_restart',
'session_recap', 'session_generation', 'session_btw', 'session_shell_command',
'mcp_workspace_pool', 'mcp_pool_restart',
Expand Down Expand Up @@ -243,7 +244,7 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design

`session_info` advertises `GET /workspace/:id/session-info` and its `/workspaces/:workspace/session-info` twin. The response aggregates persisted active and archived session counts without hydrating list metadata. It is an explicit O(n) disk scan and must not be polled; clients should treat `truncated: true` as a lower-bound result.

`session_approval_mode_control`, `workspace_tool_toggle`, `workspace_skill_toggle`, `workspace_init`, and `workspace_mcp_restart` advertise the mutation control routes documented below. They are strict-gated by the mutation gate (a daemon configured without a bearer token rejects them with 401 `token_required`). Older daemons return `404`; pre-flight each tag before exposing the corresponding affordance.
`session_approval_mode_control`, `workspace_tool_toggle`, `workspace_skill_toggle`, `workspace_skill_batch_toggle`, `workspace_init`, and `workspace_mcp_restart` advertise the mutation control routes documented below. They are strict-gated by the mutation gate (a daemon configured without a bearer token rejects them with 401 `token_required`). Older daemons return `404`; pre-flight each tag before exposing the corresponding affordance.

`mcp_guardrails` (issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175) PR 14) covers the MCP budget surface: the `clientCount` / `clientBudget` / `budgetMode` / `budgets[]` fields on `GET /workspace/mcp`, the `disabledReason` field on per-server cells, and the `--mcp-client-budget` / `--mcp-budget-mode` CLI flags. Older daemons omit the new fields entirely; SDK clients pre-flight this tag before relying on `budgets[]` semantics. The registry descriptor also carries `modes: ['warn', 'enforce']` for future feature-modes exposure — for now, clients infer mode from the snapshot's `budgetMode` field. Server refusal under `enforce` mode is deterministic by `Object.entries(mcpServers)` declaration order; a future scope-precedence layer (if qwen-code adopts one) would shift this to "lowest-precedence first" to mirror claude-code's `plugin < user < project < local` convention.

Expand Down Expand Up @@ -2704,6 +2705,53 @@ Errors:

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/skills/enable`

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

Toggle up to 100 loaded Skills in one request; the cap counts the raw `skillNames` entries before deduplication. Names are trimmed and deduplicated case-insensitively while preserving first-seen order. The daemon validates against one Skill status snapshot, persists all valid changes in one locked settings write, and refreshes active sessions once. Processing is best-effort for expected target errors: an unknown, hidden, inactive-extension, or locked target is recorded in `errors` without preventing other valid targets from being applied. Unexpected persistence or runtime-generation failures still fail the whole request.

Request:

```json
{
"skillNames": ["review", "deploy", "missing"],
"enabled": false
}
```

Response (200):

```json
{
"enabled": false,
"activation": "applied",
"sessionsRefreshed": 2,
"sessionsFailed": 0,
"results": [
{
"skillName": "review",
"enabled": false,
"changed": true
},
{
"skillName": "deploy",
"enabled": false,
"changed": true
}
],
"errors": [
{
"skillName": "missing",
"code": "skill_not_found",
Comment thread
callmeYe marked this conversation as resolved.
"error": "Skill not found: missing"
}
]
}
```

Target errors use `skill_not_found`, `skill_not_toggleable`, or `skill_inactive_extension`. Malformed requests return HTTP 400 with `invalid_skill_names`, `invalid_skill_name`, or `invalid_enabled_flag`. Authentication, workspace trust, client identity, unexpected persistence failures, and runtime-generation failures fail the whole request through the standard route gates. Batch-level `activation`, `sessionsRefreshed`, and `sessionsFailed` describe the single live-session refresh shared by all changed results. `activation` reports the refresh attempt rather than the outcome: a batch in which no target changed (for example, every target errored) still answers `applied` when a session is live, matching the single-Skill no-op response, so derive what actually changed from each result's `changed` flag and the `errors` array.

#### `POST /workspace/init`

Capability tag: `workspace_init`. Pure file IO — no ACP roundtrip, **no LLM invocation**.
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 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.
To mirror the CLI `/skills` panel remotely, call `POST /workspace/skills/:name/enable` with `{ "enabled": true | false }` after checking the `workspace_skill_toggle` capability. To change several Skills, check `workspace_skill_batch_toggle` and call `POST /workspace/skills/enable` with `{ "skillNames": ["review", "deploy"], "enabled": false }`; its response separates successful `results` from per-target `errors`, persists valid targets together, and refreshes active ACP sessions once. The routes update workspace `skills.disabled` and `skills.enabled` as needed and reject unknown, hidden, inactive-extension, higher-scope-locked, and untrusted targets. 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
1 change: 1 addition & 0 deletions integration-tests/cli/qwen-serve-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ describe('qwen serve — capabilities envelope', () => {
'session_approval_mode_control',
'workspace_tool_toggle',
'workspace_skill_toggle',
'workspace_skill_batch_toggle',
Comment thread
callmeYe marked this conversation as resolved.
'workspace_skill_manage',
Comment thread
callmeYe marked this conversation as resolved.
'workspace_settings',
'workspace_permissions',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/serve/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ export const SERVE_CAPABILITY_REGISTRY = {
// (`tools.disabled` is consulted at `Config` construction time).
workspace_tool_toggle: { since: 'v1' },
workspace_skill_toggle: { since: 'v1' },
workspace_skill_batch_toggle: { since: 'v1' },
workspace_skill_manage: { since: 'v1' },
workspace_settings: { since: 'v1' },
// `GET /workspace/permissions` is always available when this tag is
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/serve/daemon-status-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ function makeWorkspaceServiceWithProvider(
isChannelLive: opts.isChannelLive ?? (() => false),
persistDisabledTools: async () => {},
persistDisabledSkills: async () => ({ changed: false, disabled: [] }),
persistDisabledSkillsBatch: async () => ({
outcomes: [],
settingsChanges: [],
}),
queryWorkspaceStatus: opts.queryWorkspaceStatus ?? noopQueryWorkspaceStatus,
invokeWorkspaceCommand: async () => {
throw new Error('not wired');
Expand Down
Loading
Loading