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
15 changes: 11 additions & 4 deletions docs/design/daemon-skill-toggle.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# Daemon Skill Toggle

[English](daemon-skill-toggle.md) | [简体中文](daemon-skill-toggle.zh-CN.md)

## Goal

Expose workspace Skill settings writes through daemon REST and the TypeScript SDK, including immediate refresh of active ACP sessions without making the runtime Skill catalog an ownership source.
Expose workspace Skill settings writes through daemon REST and the TypeScript SDK, including immediate refresh of active ACP sessions. The runtime Skill catalog supplies identity for enable guards, but does not restrict which names may be persisted.

## Public contract

Expand All @@ -16,9 +18,13 @@ The response contains the trimmed requested name, requested state, whether persi

## Semantics

The API changes workspace `skills.disabled` and `skills.enabled` by name without consulting the runtime Skill catalog. 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 name may be disabled before installation, while hidden from user invocation, or while its Extension is inactive. Enabling removes an existing workspace disable or records an opt-in for an effective `skills.defaultDisabled` entry. An existing workspace `skills.enabled` declaration is preserved and normalized to the requested casing. With no existing workspace declaration and no effective `skills.defaultDisabled` entry, enable is a no-op (`changed: false`). A second identical request is also a no-op.
The API changes workspace `skills.disabled` and `skills.enabled` by name without requiring the name to be present in the runtime Skill catalog. 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 name may be disabled before installation, while hidden from user invocation, or while its Extension is inactive. Enabling removes an existing workspace disable or records an opt-in for an effective `skills.defaultDisabled` entry. An existing workspace `skills.enabled` declaration is preserved and normalized to the requested casing. With no existing workspace declaration and no effective `skills.defaultDisabled` entry, enable is a no-op (`changed: false`). A second identical request is also a no-op.

Before an enable guard is evaluated, the persistence closure reads the workspace Skill status once for the whole single or batch request. A catalog entry at `level: 'extension'` with `extensionName` supplies both its registry name and its authored name after the known extension prefix; an entry at any other level has only its registry spelling. An unknown name, an uninitialized status, or a failed status read falls back to the previous punctuation-derived alias behavior. This fallback preserves legacy bare-name blocks for unavailable extension Skills. Daemon-local status omits extension Skills when no live ACP child has supplied a catalog, so those names remain on the fallback path until extension enumeration is available locally. Disables do not read the catalog.

Batch enables repeatedly reconsider refused names against the evolving final workspace lists until no list changes or every name has an outcome. This fixpoint makes results independent of request order, including multi-step legacy-alias chains, while retaining one settings write.

A hard `skills.disabled` entry inherited from a higher scope remains authoritative for effective availability, but does not prevent workspace scope from recording or removing its own declaration. Workspace declarations otherwise participate in the usual `skills.disabled > skills.enabled > skills.defaultDisabled` resolution and can override higher-scope `skills.defaultDisabled` or `skills.enabled` entries. The route retains request-shape, authentication, client identity, workspace trust, and runtime-generation gates; none of those require a Skill catalog lookup.
A hard `skills.disabled` entry inherited from a higher scope remains authoritative for effective availability, but does not prevent workspace scope from recording or removing its own declaration. Workspace declarations otherwise participate in the usual `skills.disabled > skills.enabled > skills.defaultDisabled` resolution and can override higher-scope `skills.defaultDisabled` or `skills.enabled` entries. The route retains request-shape, authentication, client identity, workspace trust, and runtime-generation gates. Catalog identity resolution stays inside the persistence closure and does not add route-level validation.

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

Expand All @@ -31,7 +37,7 @@ The workspace read-modify-write happens inside the daemon's per-workspace settin
## Activation flow

1. Validate the request name, authorization, workspace trust, client identity, and runtime generation.
2. Under the workspace settings lock, re-read every scope, compute the resulting workspace declaration changes, and commit them in at most one write.
2. Under the workspace settings lock, resolve enable identities from one catalog read, re-read every scope, compute the resulting workspace declaration changes, and commit them in at most one write.
3. If no declaration changed, return `changed: false` without cache invalidation, refresh, or event publication.
4. Otherwise, invalidate the daemon's cached skill status.
5. If an ACP child is live, invoke `qwen/control/workspace/skills/refresh`.
Expand All @@ -56,6 +62,7 @@ An in-flight model request cannot be rewritten. Subsequent skill execution check
## Failure behavior

- Persistence failure: the HTTP request fails; no ACP refresh and no event.
- Skill-status read unavailable during enable: persistence uses the legacy punctuation-derived alias and continues; the request does not fail closed.
- No child after a declaration changed: persistence succeeds with `deferred`; the next child loads the setting at startup.
- No declaration change: the response reports `changed: false`; no refresh or event occurs. `activation` still reflects whether a child was live, but no activation work is needed.
- Per-session refresh failure: persistence remains committed; successful sessions stay refreshed and the response is `partial`.
Expand Down
69 changes: 69 additions & 0 deletions docs/design/daemon-skill-toggle.zh-CN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# 守护进程 Skill 切换

[English](daemon-skill-toggle.md) | [简体中文](daemon-skill-toggle.zh-CN.md)

## 目标

通过守护进程 REST 和 TypeScript SDK 写入工作区 Skill 设置,并立即刷新活跃的 ACP 会话。运行时 Skill 目录为启用保护提供身份信息,但不限制哪些名称可以被持久化。

## 公共契约

- `POST /workspace/skills/:name/enable`
- `POST /workspaces/:workspace/skills/:name/enable`
- 请求体:`{ "enabled": boolean }`
- SDK:`DaemonClient.setWorkspaceSkillEnabled` 和 `WorkspaceDaemonClient.setWorkspaceSkillEnabled`
- 能力:`workspace_skill_settings_toggle`

响应包含去除首尾空白后的请求名称、请求状态、持久化是否发生变化、激活状态以及会话刷新数量。`activation` 独立于 `changed` 反映子进程存活状态和所需刷新:`applied` 表示子进程存活且所需刷新成功,`deferred` 表示在存活检查时没有子进程,或已变更的请求在所需刷新期间失去子进程或会话,`partial` 表示持久化提交后至少有一个其他所需刷新失败。因此,无操作请求的 `changed` 仍为 false,而 `activation` 可以是 `applied` 或 `deferred`。

## 语义

API 按名称修改工作区的 `skills.disabled` 和 `skills.enabled`,但不要求该名称存在于运行时 Skill 目录中。启用默认禁用的 Skill 会写入显式选择加入;禁用会移除选择加入并写入工作区硬禁用。更新一个目标会移除其重复项和大小写变体,但不会删除不可用 Skill 的孤立条目。名称可以在安装前、对用户调用隐藏时或所属 Extension 未激活时被禁用。启用会移除已有的工作区禁用,或为有效的 `skills.defaultDisabled` 条目记录选择加入。已有的工作区 `skills.enabled` 声明会被保留,并规范为请求中的大小写。若既没有工作区声明,也没有有效的 `skills.defaultDisabled` 条目,则启用是无操作(`changed: false`)。第二次相同请求同样是无操作。

在评估启用保护前,持久化闭包会为整个单项或批量请求读取一次工作区 Skill 状态。目录中带有 `level: 'extension'` 和 `extensionName` 的条目同时提供注册名称,以及去掉已知扩展前缀后的创作名称;其他层级的条目只有注册名称。未知名称、未初始化的状态或失败的状态读取都会回退到此前依据标点推导别名的行为。该回退保留了不可用扩展 Skill 的旧式裸名称阻止语义。没有活跃 ACP 子进程提供目录时,守护进程本地状态会省略扩展 Skill,因此在本地扩展枚举可用之前,这些名称仍走回退路径。禁用操作不读取目录。

批量启用会根据不断演进的最终工作区列表,反复重新考虑先前被拒绝的名称,直到列表不再变化或所有名称都有结果。该不动点计算使结果不受请求顺序影响,包括多步旧式别名链,同时仍只写入一次设置。

从更高作用域继承的硬 `skills.disabled` 条目仍决定有效可用性,但不会阻止工作区作用域记录或移除自己的声明。其他工作区声明按照通常的 `skills.disabled > skills.enabled > skills.defaultDisabled` 规则参与解析,并可覆盖更高作用域的 `skills.defaultDisabled` 或 `skills.enabled` 条目。路由保留请求形状、认证、客户端身份、工作区信任和运行时世代保护。目录身份解析位于持久化闭包内部,不增加路由级验证。

工作区的读取、修改和写入在守护进程的逐工作区设置锁内完成。写入失败会在刷新和事件发布前停止。

## Skill 可用性与 `disable-model-invocation`

`skills.disabled` 是运维人员的硬拒绝列表,会跨作用域按不区分大小写的并集合并。`skills.defaultDisabled` 提供可覆盖的默认值,`skills.enabled` 提供显式选择加入,优先级为 `disabled > enabled > defaultDisabled`。有效禁用会移除匹配的 Skill 斜杠命令和模型可见的 Skill 条目,执行时验证也会拒绝该 Skill。守护进程端点写入 `disabled` 和 `enabled` 的工作区成员。

`disable-model-invocation` 是 SKILL.md 元数据。它对模型隐藏 Skill,但保留用户直接调用。现有的托管 Skill ACP 操作编辑该元数据,本 API 有意不复用它。

## 激活流程

1. 验证请求名称、授权、工作区信任、客户端身份和运行时世代。
2. 在工作区设置锁内,通过一次目录读取解析启用身份,重新读取所有作用域,计算最终的工作区声明变更,并最多提交一次写入。
3. 若声明没有变化,返回 `changed: false`,且不进行缓存失效、刷新或事件发布。
4. 否则,使守护进程缓存的 Skill 状态失效。
5. 若 ACP 子进程存活,调用 `qwen/control/workspace/skills/refresh`。
6. 子进程重新加载工作区作用域设置,并刷新所有活跃会话,包括忙碌会话。
7. 每个会话重新加载自己的工作区设置,重建并推送 `available_commands_update`,并通知 SkillManager 使用方。
8. 为每个变更的 Skill 设置键发布现有的工作区 `settings_changed` 事件。

进行中的模型请求无法被改写。后续 Skill 执行检查、命令快照和模型上下文会读取新状态。

## 下游使用方

- 设置合并:系统默认、用户、工作区和系统列表以 `disabled > enabled > defaultDisabled` 的优先级形成有效的禁用名称集合。
- 工作区状态:ACP 和守护进程本地 Skill 映射公开禁用状态、禁用原因、锁定作用域和仅在为 false 时出现的 `userInvocable`。
- 斜杠命令:可用命令构建会移除禁用 Skill,并向守护进程客户端发送更新后的命令元数据。
- 模型上下文:SkillManager 变更监听器会刷新 Skill 工具描述和可用 Skill 上下文。
- 执行验证:Skill 工具在调用前重新读取禁用名称提供方,因此后续调用会被立即拒绝。
- 扩展状态:未激活的 Extension 仍使其 Skill 在运行时不可用,这与工作区设置是否记录这些名称无关。
- 守护进程缓存:持久化后会使缓存的活跃子进程 Skill 快照失效,防止后续 GET 请求重放过期状态。
- SDK 使用方:主工作区客户端和工作区限定客户端共享仅包含设置结果的响应契约。
- 事件:现有的 `settings_changed` 使用方会观察每个已提交的 `skills.disabled` 或 `skills.enabled` 值;不新增事件类型。

## 失败行为

- 持久化失败:HTTP 请求失败;不进行 ACP 刷新,也不发布事件。
- 启用期间无法读取 Skill 状态:持久化使用旧式的标点推导别名并继续;请求不会以失败关闭。
- 声明变更后没有子进程:持久化成功并返回 `deferred`;下一个子进程会在启动时加载设置。
- 声明没有变化:响应报告 `changed: false`;不进行刷新或事件发布。`activation` 仍反映子进程是否存活,但无需激活工作。
- 单会话刷新失败:持久化保持已提交;成功的会话保持已刷新,响应为 `partial`。
- 子进程传输竞态:若子进程在存活检查后消失,响应为 `deferred`;其他刷新失败报告为 `partial`。
6 changes: 3 additions & 3 deletions packages/cli/src/config/skill-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,14 +417,14 @@ describe('buildHigherDisabled', () => {
});

describe('skillToggleBlockForName', () => {
it('derives the authored spelling from a qualified request name', () => {
it('uses the resolved authored spelling for a qualified request name', () => {
expect(
skillToggleBlockForName(
fakeSettings({
merged: {},
workspace: { disabled: ['pdf'] },
}),
'demo:pdf',
{ name: 'demo:pdf', authoredName: 'pdf' },
),
).toEqual({
reason: 'hard',
Expand All @@ -443,7 +443,7 @@ describe('skillToggleBlockForName', () => {
merged: {},
workspace: { disabled: ['demo:pdf'] },
}),
'pdf',
{ name: 'pdf' },
),
).toBeNull();
});
Expand Down
15 changes: 2 additions & 13 deletions packages/cli/src/config/skill-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,22 +284,11 @@ export function buildHigherDisabled(settings: LoadedSettings): {
};
}

/**
* The block decision for a toggle request that arrives as a bare settings
* string rather than a skill object: the daemon's skill routes persist the
* name a client sent verbatim. A qualified name carries its authored
* spelling after the prefix, which is the spelling a legacy entry holds;
* a name without a prefix has one spelling and no legacy alias.
*/
export function skillToggleBlockForName(
settings: LoadedSettings,
skillName: string,
skill: { name: string; authoredName?: string },
): SkillToggleBlock | null {
const prefixEnd = skillName.indexOf(':');
return buildHigherDisabled(settings).blockIn({
name: skillName,
...(prefixEnd > 0 ? { authoredName: skillName.slice(prefixEnd + 1) } : {}),
});
return buildHigherDisabled(settings).blockIn(skill);
}

function updateTarget(
Expand Down
Loading
Loading