diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go index 9d2a3416..7b093c9c 100644 --- a/clients/go/ahp/reducers.go +++ b/clients/go/ahp/reducers.go @@ -367,39 +367,68 @@ func containerChildren(c *ahptypes.Customization) *[]ahptypes.ChildCustomization return nil } -func setContainerEnabled(c *ahptypes.Customization, enabled bool) { +func effectiveEnablement(enablement []ahptypes.CustomizationEnablement) bool { + if len(enablement) == 0 { + return true + } + switch decision := enablement[0].Value.(type) { + case *ahptypes.CustomizationEnablementGlobal: + return decision.Enabled + case *ahptypes.CustomizationEnablementWorkspace: + return decision.Enabled + case *ahptypes.CustomizationEnablementSession: + return decision.Enabled + default: + return true + } +} + +func applyContainerEnablement(c *ahptypes.Customization, enablement []ahptypes.CustomizationEnablement) { + enabled := effectiveEnablement(enablement) + provenance := append([]ahptypes.CustomizationEnablement(nil), enablement...) switch v := c.Value.(type) { case *ahptypes.PluginCustomization: v.Enabled = enabled + v.Enablement = provenance case *ahptypes.DirectoryCustomization: v.Enabled = enabled + v.Enablement = provenance case *ahptypes.McpServerCustomization: v.Enabled = enabled + v.Enablement = provenance } } -func setChildEnabled(c *ahptypes.ChildCustomization, enabled bool) { +func applyChildEnablement(c *ahptypes.ChildCustomization, enablement []ahptypes.CustomizationEnablement) { + enabled := effectiveEnablement(enablement) + provenance := append([]ahptypes.CustomizationEnablement(nil), enablement...) switch v := c.Value.(type) { case *ahptypes.AgentCustomization: v.Enabled = &enabled + v.Enablement = provenance case *ahptypes.SkillCustomization: v.Enabled = &enabled + v.Enablement = provenance case *ahptypes.PromptCustomization: v.Enabled = &enabled + v.Enablement = provenance case *ahptypes.RuleCustomization: v.Enabled = &enabled + v.Enablement = provenance case *ahptypes.HookCustomization: v.Enabled = &enabled + v.Enablement = provenance case *ahptypes.McpServerCustomization: v.Enabled = enabled + v.Enablement = provenance } } -func applyToggle(list []ahptypes.Customization, id string, enabled bool) bool { +func applyToggle(list []ahptypes.Customization, id string, enablement []ahptypes.CustomizationEnablement) bool { for i := range list { got, ok := customizationID(list[i]) if ok && got == id { - setContainerEnabled(&list[i], enabled) + applyContainerEnablement(&list[i], enablement) return true } } @@ -411,7 +440,7 @@ func applyToggle(list []ahptypes.Customization, id string, enabled bool) bool { for j := range *children { got, ok := childCustomizationID((*children)[j]) if ok && got == id { - setChildEnabled(&(*children)[j], enabled) + applyChildEnablement(&(*children)[j], enablement) return true } } @@ -938,7 +967,7 @@ func ApplyActionToSession(state *ahptypes.SessionState, action ahptypes.StateAct if state.Customizations == nil { return ReduceOutcomeNoOp } - if applyToggle(state.Customizations, a.Id, a.Enabled) { + if applyToggle(state.Customizations, a.Id, a.Enablement) { return ReduceOutcomeApplied } return ReduceOutcomeNoOp diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index fccdb904..d9a98a24 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -992,12 +992,15 @@ type SessionCustomizationsChangedAction struct { // `container.enabled && (child.enabled ?? true)` — so toggling a child // only matters while its container is enabled. Is a no-op when no // customization has the given `id`. +// +// The `enablement` array completely replaces all explicit decisions. A caller +// changing one scope must include every decision it intends to preserve. type SessionCustomizationToggledAction struct { Type ActionType `json:"type"` - // The id of the container or child to toggle. + // The id of the container or child to update. Id string `json:"id"` - // Whether to enable or disable the targeted customization. - Enabled bool `json:"enabled"` + // The complete set of explicit decisions, replacing any existing set. + Enablement []CustomizationEnablement `json:"enablement"` } // Upserts a top-level customization (plugin or directory). diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index 99693a7b..182d11b3 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -320,6 +320,15 @@ const ( CustomizationTypeMcpServer CustomizationType = "mcpServer" ) +// Scope at which customization enablement is decided. +type CustomizationEnablementKind string + +const ( + CustomizationEnablementKindGlobal CustomizationEnablementKind = "global" + CustomizationEnablementKindWorkspace CustomizationEnablementKind = "workspace" + CustomizationEnablementKindSession CustomizationEnablementKind = "session" +) + // Discriminant values for {@link CustomizationLoadState}. type CustomizationLoadStatus string @@ -2384,6 +2393,18 @@ type PluginCustomization struct { Uri URI `json:"uri"` // Human-readable name. Name string `json:"name"` + // Explicit enablement decisions for this customization, one entry per scope + // that has one. This is a wire contract: producers MUST publish entries + // sorted by descending specificity (Session, Workspace, then Global). + // The agent host emits at most one Workspace entry, for the session's primary + // working directory. Consumers MAY treat + // `enablement[0]` as the decisive decision and + // `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + // absent or empty array means no explicit decision exists, so the + // customization is enabled by default. + // + // Only the agent host publishes this; clients treat it as read-only provenance. + Enablement []CustomizationEnablement `json:"enablement,omitempty"` // Icons for UI display. Icons []Icon `json:"icons,omitempty"` // Optional span within {@link CustomizationBase.uri | `uri`} when this @@ -2445,6 +2466,18 @@ type ClientPluginCustomization struct { Uri URI `json:"uri"` // Human-readable name. Name string `json:"name"` + // Explicit enablement decisions for this customization, one entry per scope + // that has one. This is a wire contract: producers MUST publish entries + // sorted by descending specificity (Session, Workspace, then Global). + // The agent host emits at most one Workspace entry, for the session's primary + // working directory. Consumers MAY treat + // `enablement[0]` as the decisive decision and + // `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + // absent or empty array means no explicit decision exists, so the + // customization is enabled by default. + // + // Only the agent host publishes this; clients treat it as read-only provenance. + Enablement []CustomizationEnablement `json:"enablement,omitempty"` // Icons for UI display. Icons []Icon `json:"icons,omitempty"` // Optional span within {@link CustomizationBase.uri | `uri`} when this @@ -2508,6 +2541,18 @@ type DirectoryCustomization struct { Uri URI `json:"uri"` // Human-readable name. Name string `json:"name"` + // Explicit enablement decisions for this customization, one entry per scope + // that has one. This is a wire contract: producers MUST publish entries + // sorted by descending specificity (Session, Workspace, then Global). + // The agent host emits at most one Workspace entry, for the session's primary + // working directory. Consumers MAY treat + // `enablement[0]` as the decisive decision and + // `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + // absent or empty array means no explicit decision exists, so the + // customization is enabled by default. + // + // Only the agent host publishes this; clients treat it as read-only provenance. + Enablement []CustomizationEnablement `json:"enablement,omitempty"` // Icons for UI display. Icons []Icon `json:"icons,omitempty"` // Optional span within {@link CustomizationBase.uri | `uri`} when this @@ -2562,6 +2607,18 @@ type AgentCustomization struct { Uri URI `json:"uri"` // Human-readable name. Name string `json:"name"` + // Explicit enablement decisions for this customization, one entry per scope + // that has one. This is a wire contract: producers MUST publish entries + // sorted by descending specificity (Session, Workspace, then Global). + // The agent host emits at most one Workspace entry, for the session's primary + // working directory. Consumers MAY treat + // `enablement[0]` as the decisive decision and + // `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + // absent or empty array means no explicit decision exists, so the + // customization is enabled by default. + // + // Only the agent host publishes this; clients treat it as read-only provenance. + Enablement []CustomizationEnablement `json:"enablement,omitempty"` // Icons for UI display. Icons []Icon `json:"icons,omitempty"` // Optional span within {@link CustomizationBase.uri | `uri`} when this @@ -2634,6 +2691,18 @@ type SkillCustomization struct { Uri URI `json:"uri"` // Human-readable name. Name string `json:"name"` + // Explicit enablement decisions for this customization, one entry per scope + // that has one. This is a wire contract: producers MUST publish entries + // sorted by descending specificity (Session, Workspace, then Global). + // The agent host emits at most one Workspace entry, for the session's primary + // working directory. Consumers MAY treat + // `enablement[0]` as the decisive decision and + // `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + // absent or empty array means no explicit decision exists, so the + // customization is enabled by default. + // + // Only the agent host publishes this; clients treat it as read-only provenance. + Enablement []CustomizationEnablement `json:"enablement,omitempty"` // Icons for UI display. Icons []Icon `json:"icons,omitempty"` // Optional span within {@link CustomizationBase.uri | `uri`} when this @@ -2689,6 +2758,18 @@ type PromptCustomization struct { Uri URI `json:"uri"` // Human-readable name. Name string `json:"name"` + // Explicit enablement decisions for this customization, one entry per scope + // that has one. This is a wire contract: producers MUST publish entries + // sorted by descending specificity (Session, Workspace, then Global). + // The agent host emits at most one Workspace entry, for the session's primary + // working directory. Consumers MAY treat + // `enablement[0]` as the decisive decision and + // `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + // absent or empty array means no explicit decision exists, so the + // customization is enabled by default. + // + // Only the agent host publishes this; clients treat it as read-only provenance. + Enablement []CustomizationEnablement `json:"enablement,omitempty"` // Icons for UI display. Icons []Icon `json:"icons,omitempty"` // Optional span within {@link CustomizationBase.uri | `uri`} when this @@ -2743,6 +2824,18 @@ type RuleCustomization struct { Uri URI `json:"uri"` // Human-readable name. Name string `json:"name"` + // Explicit enablement decisions for this customization, one entry per scope + // that has one. This is a wire contract: producers MUST publish entries + // sorted by descending specificity (Session, Workspace, then Global). + // The agent host emits at most one Workspace entry, for the session's primary + // working directory. Consumers MAY treat + // `enablement[0]` as the decisive decision and + // `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + // absent or empty array means no explicit decision exists, so the + // customization is enabled by default. + // + // Only the agent host publishes this; clients treat it as read-only provenance. + Enablement []CustomizationEnablement `json:"enablement,omitempty"` // Icons for UI display. Icons []Icon `json:"icons,omitempty"` // Optional span within {@link CustomizationBase.uri | `uri`} when this @@ -2796,6 +2889,18 @@ type HookCustomization struct { Uri URI `json:"uri"` // Human-readable name. Name string `json:"name"` + // Explicit enablement decisions for this customization, one entry per scope + // that has one. This is a wire contract: producers MUST publish entries + // sorted by descending specificity (Session, Workspace, then Global). + // The agent host emits at most one Workspace entry, for the session's primary + // working directory. Consumers MAY treat + // `enablement[0]` as the decisive decision and + // `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + // absent or empty array means no explicit decision exists, so the + // customization is enabled by default. + // + // Only the agent host publishes this; clients treat it as read-only provenance. + Enablement []CustomizationEnablement `json:"enablement,omitempty"` // Icons for UI display. Icons []Icon `json:"icons,omitempty"` // Optional span within {@link CustomizationBase.uri | `uri`} when this @@ -2847,6 +2952,18 @@ type McpServerCustomization struct { Uri URI `json:"uri"` // Human-readable name. Name string `json:"name"` + // Explicit enablement decisions for this customization, one entry per scope + // that has one. This is a wire contract: producers MUST publish entries + // sorted by descending specificity (Session, Workspace, then Global). + // The agent host emits at most one Workspace entry, for the session's primary + // working directory. Consumers MAY treat + // `enablement[0]` as the decisive decision and + // `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + // absent or empty array means no explicit decision exists, so the + // customization is enabled by default. + // + // Only the agent host publishes this; clients treat it as read-only provenance. + Enablement []CustomizationEnablement `json:"enablement,omitempty"` // Icons for UI display. Icons []Icon `json:"icons,omitempty"` // Optional span within {@link CustomizationBase.uri | `uri`} when this @@ -2861,7 +2978,8 @@ type McpServerCustomization struct { // out-of-band. Meta map[string]json.RawMessage `json:"_meta,omitempty"` Type CustomizationType `json:"type"` - // Whether this MCP server is currently enabled. + // Whether this MCP server is effectively enabled after resolving all scopes. + // {@link CustomizationBase.enablement | `enablement`} records its inputs. Enabled bool `json:"enabled"` // Current lifecycle state of the MCP server. State McpServerState `json:"state"` @@ -3557,6 +3675,74 @@ type ResourceChange struct { Type ResourceChangeType `json:"type"` } +// ─── Customization Enablement Union ─────────────────────────────────────── + +// CustomizationEnablement is a single explicit customization enablement decision. +type CustomizationEnablement struct { + Value isCustomizationEnablement +} + +type isCustomizationEnablement interface{ isCustomizationEnablement() } + +type CustomizationEnablementGlobal struct { + Kind string `json:"kind"` + Enabled bool `json:"enabled"` +} + +func (*CustomizationEnablementGlobal) isCustomizationEnablement() {} + +type CustomizationEnablementWorkspace struct { + Kind string `json:"kind"` + URI URI `json:"uri"` + Enabled bool `json:"enabled"` +} + +func (*CustomizationEnablementWorkspace) isCustomizationEnablement() {} + +type CustomizationEnablementSession struct { + Kind string `json:"kind"` + Enabled bool `json:"enabled"` +} + +func (*CustomizationEnablementSession) isCustomizationEnablement() {} + +func (e *CustomizationEnablement) UnmarshalJSON(data []byte) error { + disc, _, err := readDiscriminator(data, "kind") + if err != nil { + return err + } + switch disc { + case "global": + var value CustomizationEnablementGlobal + if err := json.Unmarshal(data, &value); err != nil { + return err + } + e.Value = &value + case "workspace": + var value CustomizationEnablementWorkspace + if err := json.Unmarshal(data, &value); err != nil { + return err + } + e.Value = &value + case "session": + var value CustomizationEnablementSession + if err := json.Unmarshal(data, &value); err != nil { + return err + } + e.Value = &value + default: + return &json.UnmarshalTypeError{Value: "CustomizationEnablement"} + } + return nil +} + +func (e CustomizationEnablement) MarshalJSON() ([]byte, error) { + if e.Value == nil { + return []byte("null"), nil + } + return json.Marshal(e.Value) +} + // ToolInput is raw tool input represented inline or by content reference. type ToolInput struct { Inline *string diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt index 6222ed85..0ab85ee9 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -271,21 +271,36 @@ private fun withCustomizationChildren(c: Customization, children: List c } -private fun withCustomizationEnabled(c: Customization, enabled: Boolean): Customization = when (c) { - is CustomizationPlugin -> CustomizationPlugin(c.value.copy(enabled = enabled)) - is CustomizationDirectory -> CustomizationDirectory(c.value.copy(enabled = enabled)) - is CustomizationMcpServer -> CustomizationMcpServer(c.value.copy(enabled = enabled)) +private fun effectiveEnablement(enablement: List): Boolean = when (val decision = enablement.firstOrNull()) { + is CustomizationEnablement.Global -> decision.value.enabled + is CustomizationEnablement.Workspace -> decision.value.enabled + is CustomizationEnablement.Session -> decision.value.enabled + null -> true +} + +private fun withCustomizationEnablement(c: Customization, enablement: List): Customization { + val enabled = effectiveEnablement(enablement) + val provenance = enablement.takeIf { it.isNotEmpty() }?.toList() + return when (c) { + is CustomizationPlugin -> CustomizationPlugin(c.value.copy(enabled = enabled, enablement = provenance)) + is CustomizationDirectory -> CustomizationDirectory(c.value.copy(enabled = enabled, enablement = provenance)) + is CustomizationMcpServer -> CustomizationMcpServer(c.value.copy(enabled = enabled, enablement = provenance)) is CustomizationUnknown -> c + } } -private fun withChildCustomizationEnabled(c: ChildCustomization, enabled: Boolean): ChildCustomization = when (c) { - is ChildCustomizationAgent -> ChildCustomizationAgent(c.value.copy(enabled = enabled)) - is ChildCustomizationSkill -> ChildCustomizationSkill(c.value.copy(enabled = enabled)) - is ChildCustomizationPrompt -> ChildCustomizationPrompt(c.value.copy(enabled = enabled)) - is ChildCustomizationRule -> ChildCustomizationRule(c.value.copy(enabled = enabled)) - is ChildCustomizationHook -> ChildCustomizationHook(c.value.copy(enabled = enabled)) - is ChildCustomizationMcpServer -> ChildCustomizationMcpServer(c.value.copy(enabled = enabled)) +private fun withChildCustomizationEnablement(c: ChildCustomization, enablement: List): ChildCustomization { + val enabled = effectiveEnablement(enablement) + val provenance = enablement.takeIf { it.isNotEmpty() }?.toList() + return when (c) { + is ChildCustomizationAgent -> ChildCustomizationAgent(c.value.copy(enabled = enabled, enablement = provenance)) + is ChildCustomizationSkill -> ChildCustomizationSkill(c.value.copy(enabled = enabled, enablement = provenance)) + is ChildCustomizationPrompt -> ChildCustomizationPrompt(c.value.copy(enabled = enabled, enablement = provenance)) + is ChildCustomizationRule -> ChildCustomizationRule(c.value.copy(enabled = enabled, enablement = provenance)) + is ChildCustomizationHook -> ChildCustomizationHook(c.value.copy(enabled = enabled, enablement = provenance)) + is ChildCustomizationMcpServer -> ChildCustomizationMcpServer(c.value.copy(enabled = enabled, enablement = provenance)) is ChildCustomizationUnknown -> c + } } private fun childCustomizationId(c: ChildCustomization): String? = when (c) { @@ -685,7 +700,7 @@ public fun sessionReducer(state: SessionState, action: StateAction): SessionStat val idx = list.indexOfFirst { customizationId(it) == a.id } if (idx >= 0) { val updated = list.toMutableList() - updated[idx] = withCustomizationEnabled(updated[idx], a.enabled) + updated[idx] = withCustomizationEnablement(updated[idx], a.enablement) state.copy(customizations = updated) } else run { for (i in list.indices) { @@ -693,7 +708,7 @@ public fun sessionReducer(state: SessionState, action: StateAction): SessionStat val childIdx = children.indexOfFirst { childCustomizationId(it) == a.id } if (childIdx < 0) continue val newChildren = children.toMutableList() - newChildren[childIdx] = withChildCustomizationEnabled(newChildren[childIdx], a.enabled) + newChildren[childIdx] = withChildCustomizationEnablement(newChildren[childIdx], a.enablement) val updated = list.toMutableList() updated[i] = withCustomizationChildren(list[i], newChildren) return@run state.copy(customizations = updated) diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt index cde9f9ed..b0bc05d5 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt @@ -1058,13 +1058,13 @@ data class SessionCustomizationsChangedAction( data class SessionCustomizationToggledAction( val type: ActionType, /** - * The id of the container or child to toggle. + * The id of the container or child to update. */ val id: String, /** - * Whether to enable or disable the targeted customization. + * The complete set of explicit decisions, replacing any existing set. */ - val enabled: Boolean + val enablement: List ) @Serializable diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt index d6fe2b4e..638fc681 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt @@ -557,6 +557,19 @@ enum class CustomizationType { MCP_SERVER } +/** + * Scope at which customization enablement is decided. + */ +@Serializable +enum class CustomizationEnablementKind { + @SerialName("global") + GLOBAL, + @SerialName("workspace") + WORKSPACE, + @SerialName("session") + SESSION +} + /** * Discriminant values for {@link CustomizationLoadState}. */ @@ -3256,6 +3269,20 @@ data class PluginCustomization( * Human-readable name. */ val name: String, + /** + * Explicit enablement decisions for this customization, one entry per scope + * that has one. This is a wire contract: producers MUST publish entries + * sorted by descending specificity (Session, Workspace, then Global). + * The agent host emits at most one Workspace entry, for the session's primary + * working directory. Consumers MAY treat + * `enablement[0]` as the decisive decision and + * `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + * absent or empty array means no explicit decision exists, so the + * customization is enabled by default. + * + * Only the agent host publishes this; clients treat it as read-only provenance. + */ + val enablement: List? = null, /** * Icons for UI display. */ @@ -3332,6 +3359,20 @@ data class ClientPluginCustomization( * Human-readable name. */ val name: String, + /** + * Explicit enablement decisions for this customization, one entry per scope + * that has one. This is a wire contract: producers MUST publish entries + * sorted by descending specificity (Session, Workspace, then Global). + * The agent host emits at most one Workspace entry, for the session's primary + * working directory. Consumers MAY treat + * `enablement[0]` as the decisive decision and + * `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + * absent or empty array means no explicit decision exists, so the + * customization is enabled by default. + * + * Only the agent host publishes this; clients treat it as read-only provenance. + */ + val enablement: List? = null, /** * Icons for UI display. */ @@ -3412,6 +3453,20 @@ data class DirectoryCustomization( * Human-readable name. */ val name: String, + /** + * Explicit enablement decisions for this customization, one entry per scope + * that has one. This is a wire contract: producers MUST publish entries + * sorted by descending specificity (Session, Workspace, then Global). + * The agent host emits at most one Workspace entry, for the session's primary + * working directory. Consumers MAY treat + * `enablement[0]` as the decisive decision and + * `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + * absent or empty array means no explicit decision exists, so the + * customization is enabled by default. + * + * Only the agent host publishes this; clients treat it as read-only provenance. + */ + val enablement: List? = null, /** * Icons for UI display. */ @@ -3487,6 +3542,20 @@ data class AgentCustomization( * Human-readable name. */ val name: String, + /** + * Explicit enablement decisions for this customization, one entry per scope + * that has one. This is a wire contract: producers MUST publish entries + * sorted by descending specificity (Session, Workspace, then Global). + * The agent host emits at most one Workspace entry, for the session's primary + * working directory. Consumers MAY treat + * `enablement[0]` as the decisive decision and + * `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + * absent or empty array means no explicit decision exists, so the + * customization is enabled by default. + * + * Only the agent host publishes this; clients treat it as read-only provenance. + */ + val enablement: List? = null, /** * Icons for UI display. */ @@ -3579,6 +3648,20 @@ data class SkillCustomization( * Human-readable name. */ val name: String, + /** + * Explicit enablement decisions for this customization, one entry per scope + * that has one. This is a wire contract: producers MUST publish entries + * sorted by descending specificity (Session, Workspace, then Global). + * The agent host emits at most one Workspace entry, for the session's primary + * working directory. Consumers MAY treat + * `enablement[0]` as the decisive decision and + * `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + * absent or empty array means no explicit decision exists, so the + * customization is enabled by default. + * + * Only the agent host publishes this; clients treat it as read-only provenance. + */ + val enablement: List? = null, /** * Icons for UI display. */ @@ -3655,6 +3738,20 @@ data class PromptCustomization( * Human-readable name. */ val name: String, + /** + * Explicit enablement decisions for this customization, one entry per scope + * that has one. This is a wire contract: producers MUST publish entries + * sorted by descending specificity (Session, Workspace, then Global). + * The agent host emits at most one Workspace entry, for the session's primary + * working directory. Consumers MAY treat + * `enablement[0]` as the decisive decision and + * `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + * absent or empty array means no explicit decision exists, so the + * customization is enabled by default. + * + * Only the agent host publishes this; clients treat it as read-only provenance. + */ + val enablement: List? = null, /** * Icons for UI display. */ @@ -3718,6 +3815,20 @@ data class RuleCustomization( * Human-readable name. */ val name: String, + /** + * Explicit enablement decisions for this customization, one entry per scope + * that has one. This is a wire contract: producers MUST publish entries + * sorted by descending specificity (Session, Workspace, then Global). + * The agent host emits at most one Workspace entry, for the session's primary + * working directory. Consumers MAY treat + * `enablement[0]` as the decisive decision and + * `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + * absent or empty array means no explicit decision exists, so the + * customization is enabled by default. + * + * Only the agent host publishes this; clients treat it as read-only provenance. + */ + val enablement: List? = null, /** * Icons for UI display. */ @@ -3792,6 +3903,20 @@ data class HookCustomization( * Human-readable name. */ val name: String, + /** + * Explicit enablement decisions for this customization, one entry per scope + * that has one. This is a wire contract: producers MUST publish entries + * sorted by descending specificity (Session, Workspace, then Global). + * The agent host emits at most one Workspace entry, for the session's primary + * working directory. Consumers MAY treat + * `enablement[0]` as the decisive decision and + * `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + * absent or empty array means no explicit decision exists, so the + * customization is enabled by default. + * + * Only the agent host publishes this; clients treat it as read-only provenance. + */ + val enablement: List? = null, /** * Icons for UI display. */ @@ -3851,6 +3976,20 @@ data class McpServerCustomization( * Human-readable name. */ val name: String, + /** + * Explicit enablement decisions for this customization, one entry per scope + * that has one. This is a wire contract: producers MUST publish entries + * sorted by descending specificity (Session, Workspace, then Global). + * The agent host emits at most one Workspace entry, for the session's primary + * working directory. Consumers MAY treat + * `enablement[0]` as the decisive decision and + * `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + * absent or empty array means no explicit decision exists, so the + * customization is enabled by default. + * + * Only the agent host publishes this; clients treat it as read-only provenance. + */ + val enablement: List? = null, /** * Icons for UI display. */ @@ -3873,7 +4012,8 @@ data class McpServerCustomization( val meta: Map? = null, val type: CustomizationType, /** - * Whether this MCP server is currently enabled. + * Whether this MCP server is effectively enabled after resolving all scopes. + * {@link CustomizationBase.enablement | `enablement`} records its inputs. */ val enabled: Boolean, /** @@ -4662,6 +4802,76 @@ data class ResourceChange( val type: ResourceChangeType ) +// ─── Customization Enablement Union ───────────────────────────────────── + +/** + * A single explicit customization enablement decision. + */ +@Serializable(with = CustomizationEnablementSerializer::class) +sealed interface CustomizationEnablement { + @JvmInline value class Global(val value: CustomizationEnablementGlobal) : CustomizationEnablement + @JvmInline value class Workspace(val value: CustomizationEnablementWorkspace) : CustomizationEnablement + @JvmInline value class Session(val value: CustomizationEnablementSession) : CustomizationEnablement +} + +@Serializable +data class CustomizationEnablementGlobal( + val enabled: Boolean, + val kind: String = "global", +) + +@Serializable +data class CustomizationEnablementWorkspace( + val uri: URI, + val enabled: Boolean, + val kind: String = "workspace", +) + +@Serializable +data class CustomizationEnablementSession( + val enabled: Boolean, + val kind: String = "session", +) + +internal object CustomizationEnablementSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("CustomizationEnablement") + + override fun deserialize(decoder: Decoder): CustomizationEnablement { + val input = decoder as? JsonDecoder + ?: error("CustomizationEnablement can only be deserialized from JSON") + val element = input.decodeJsonElement() + val obj = element as? JsonObject + ?: error("Expected JsonObject for CustomizationEnablement") + return when ((obj["kind"] as? JsonPrimitive)?.contentOrNull) { + "global" -> CustomizationEnablement.Global( + input.json.decodeFromJsonElement(CustomizationEnablementGlobal.serializer(), element), + ) + "workspace" -> CustomizationEnablement.Workspace( + input.json.decodeFromJsonElement(CustomizationEnablementWorkspace.serializer(), element), + ) + "session" -> CustomizationEnablement.Session( + input.json.decodeFromJsonElement(CustomizationEnablementSession.serializer(), element), + ) + else -> error("Unknown CustomizationEnablement kind") + } + } + + override fun serialize(encoder: Encoder, value: CustomizationEnablement) { + val output = encoder as? JsonEncoder + ?: error("CustomizationEnablement can only be serialized to JSON") + val element: JsonElement = when (value) { + is CustomizationEnablement.Global -> + output.json.encodeToJsonElement(CustomizationEnablementGlobal.serializer(), value.value) + is CustomizationEnablement.Workspace -> + output.json.encodeToJsonElement(CustomizationEnablementWorkspace.serializer(), value.value) + is CustomizationEnablement.Session -> + output.json.encodeToJsonElement(CustomizationEnablementSession.serializer(), value.value) + } + output.encodeJsonElement(element) + } +} + // ─── Tool Input ────────────────────────────────────────────────────────────── /** diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index 849a339c..0f4f83af 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -16,11 +16,12 @@ use crate::state::{ AgentInfo, AgentSelection, Annotation, AnnotationEntry, Changeset, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, ChangesetStatus, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ChatSummary, - ConfirmationOption, ContentRef, Customization, ErrorInfo, McpAuthRequirement, McpServerState, - Message, ModelSelection, PendingMessageKind, ResponsePart, SessionActiveClient, - SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, TextRange, - ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallContributor, ToolCallResult, - ToolCallRiskAssessment, ToolDefinition, ToolInput, ToolResultContent, Turn, UsageInfo, + ConfirmationOption, ContentRef, Customization, CustomizationEnablement, ErrorInfo, + McpAuthRequirement, McpServerState, Message, ModelSelection, PendingMessageKind, ResponsePart, + SessionActiveClient, SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, + TextRange, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallContributor, + ToolCallResult, ToolCallRiskAssessment, ToolDefinition, ToolInput, ToolResultContent, Turn, + UsageInfo, }; // ─── ActionType ────────────────────────────────────────────────────── @@ -1168,13 +1169,16 @@ pub struct SessionCustomizationsChangedAction { /// `container.enabled && (child.enabled ?? true)` — so toggling a child /// only matters while its container is enabled. Is a no-op when no /// customization has the given `id`. +/// +/// The `enablement` array completely replaces all explicit decisions. A caller +/// changing one scope must include every decision it intends to preserve. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionCustomizationToggledAction { - /// The id of the container or child to toggle. + /// The id of the container or child to update. pub id: String, - /// Whether to enable or disable the targeted customization. - pub enabled: bool, + /// The complete set of explicit decisions, replacing any existing set. + pub enablement: Vec, } /// Upserts a top-level customization (plugin or directory). diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index 0e9f5cba..bdedec6d 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -446,6 +446,17 @@ pub enum CustomizationType { McpServer, } +/// Scope at which customization enablement is decided. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum CustomizationEnablementKind { + #[serde(rename = "global")] + Global, + #[serde(rename = "workspace")] + Workspace, + #[serde(rename = "session")] + Session, +} + /// Discriminant values for {@link CustomizationLoadState}. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum CustomizationLoadStatus { @@ -2913,6 +2924,19 @@ pub struct PluginCustomization { pub uri: Uri, /// Human-readable name. pub name: String, + /// Explicit enablement decisions for this customization, one entry per scope + /// that has one. This is a wire contract: producers MUST publish entries + /// sorted by descending specificity (Session, Workspace, then Global). + /// The agent host emits at most one Workspace entry, for the session's primary + /// working directory. Consumers MAY treat + /// `enablement[0]` as the decisive decision and + /// `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + /// absent or empty array means no explicit decision exists, so the + /// customization is enabled by default. + /// + /// Only the agent host publishes this; clients treat it as read-only provenance. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enablement: Option>, /// Icons for UI display. #[serde(default, skip_serializing_if = "Option::is_none")] pub icons: Option>, @@ -2982,6 +3006,19 @@ pub struct ClientPluginCustomization { pub uri: Uri, /// Human-readable name. pub name: String, + /// Explicit enablement decisions for this customization, one entry per scope + /// that has one. This is a wire contract: producers MUST publish entries + /// sorted by descending specificity (Session, Workspace, then Global). + /// The agent host emits at most one Workspace entry, for the session's primary + /// working directory. Consumers MAY treat + /// `enablement[0]` as the decisive decision and + /// `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + /// absent or empty array means no explicit decision exists, so the + /// customization is enabled by default. + /// + /// Only the agent host publishes this; clients treat it as read-only provenance. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enablement: Option>, /// Icons for UI display. #[serde(default, skip_serializing_if = "Option::is_none")] pub icons: Option>, @@ -3054,6 +3091,19 @@ pub struct DirectoryCustomization { pub uri: Uri, /// Human-readable name. pub name: String, + /// Explicit enablement decisions for this customization, one entry per scope + /// that has one. This is a wire contract: producers MUST publish entries + /// sorted by descending specificity (Session, Workspace, then Global). + /// The agent host emits at most one Workspace entry, for the session's primary + /// working directory. Consumers MAY treat + /// `enablement[0]` as the decisive decision and + /// `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + /// absent or empty array means no explicit decision exists, so the + /// customization is enabled by default. + /// + /// Only the agent host publishes this; clients treat it as read-only provenance. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enablement: Option>, /// Icons for UI display. #[serde(default, skip_serializing_if = "Option::is_none")] pub icons: Option>, @@ -3115,6 +3165,19 @@ pub struct AgentCustomization { pub uri: Uri, /// Human-readable name. pub name: String, + /// Explicit enablement decisions for this customization, one entry per scope + /// that has one. This is a wire contract: producers MUST publish entries + /// sorted by descending specificity (Session, Workspace, then Global). + /// The agent host emits at most one Workspace entry, for the session's primary + /// working directory. Consumers MAY treat + /// `enablement[0]` as the decisive decision and + /// `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + /// absent or empty array means no explicit decision exists, so the + /// customization is enabled by default. + /// + /// Only the agent host publishes this; clients treat it as read-only provenance. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enablement: Option>, /// Icons for UI display. #[serde(default, skip_serializing_if = "Option::is_none")] pub icons: Option>, @@ -3197,6 +3260,19 @@ pub struct SkillCustomization { pub uri: Uri, /// Human-readable name. pub name: String, + /// Explicit enablement decisions for this customization, one entry per scope + /// that has one. This is a wire contract: producers MUST publish entries + /// sorted by descending specificity (Session, Workspace, then Global). + /// The agent host emits at most one Workspace entry, for the session's primary + /// working directory. Consumers MAY treat + /// `enablement[0]` as the decisive decision and + /// `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + /// absent or empty array means no explicit decision exists, so the + /// customization is enabled by default. + /// + /// Only the agent host publishes this; clients treat it as read-only provenance. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enablement: Option>, /// Icons for UI display. #[serde(default, skip_serializing_if = "Option::is_none")] pub icons: Option>, @@ -3260,6 +3336,19 @@ pub struct PromptCustomization { pub uri: Uri, /// Human-readable name. pub name: String, + /// Explicit enablement decisions for this customization, one entry per scope + /// that has one. This is a wire contract: producers MUST publish entries + /// sorted by descending specificity (Session, Workspace, then Global). + /// The agent host emits at most one Workspace entry, for the session's primary + /// working directory. Consumers MAY treat + /// `enablement[0]` as the decisive decision and + /// `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + /// absent or empty array means no explicit decision exists, so the + /// customization is enabled by default. + /// + /// Only the agent host publishes this; clients treat it as read-only provenance. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enablement: Option>, /// Icons for UI display. #[serde(default, skip_serializing_if = "Option::is_none")] pub icons: Option>, @@ -3320,6 +3409,19 @@ pub struct RuleCustomization { pub uri: Uri, /// Human-readable name. pub name: String, + /// Explicit enablement decisions for this customization, one entry per scope + /// that has one. This is a wire contract: producers MUST publish entries + /// sorted by descending specificity (Session, Workspace, then Global). + /// The agent host emits at most one Workspace entry, for the session's primary + /// working directory. Consumers MAY treat + /// `enablement[0]` as the decisive decision and + /// `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + /// absent or empty array means no explicit decision exists, so the + /// customization is enabled by default. + /// + /// Only the agent host publishes this; clients treat it as read-only provenance. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enablement: Option>, /// Icons for UI display. #[serde(default, skip_serializing_if = "Option::is_none")] pub icons: Option>, @@ -3381,6 +3483,19 @@ pub struct HookCustomization { pub uri: Uri, /// Human-readable name. pub name: String, + /// Explicit enablement decisions for this customization, one entry per scope + /// that has one. This is a wire contract: producers MUST publish entries + /// sorted by descending specificity (Session, Workspace, then Global). + /// The agent host emits at most one Workspace entry, for the session's primary + /// working directory. Consumers MAY treat + /// `enablement[0]` as the decisive decision and + /// `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + /// absent or empty array means no explicit decision exists, so the + /// customization is enabled by default. + /// + /// Only the agent host publishes this; clients treat it as read-only provenance. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enablement: Option>, /// Icons for UI display. #[serde(default, skip_serializing_if = "Option::is_none")] pub icons: Option>, @@ -3437,6 +3552,19 @@ pub struct McpServerCustomization { pub uri: Uri, /// Human-readable name. pub name: String, + /// Explicit enablement decisions for this customization, one entry per scope + /// that has one. This is a wire contract: producers MUST publish entries + /// sorted by descending specificity (Session, Workspace, then Global). + /// The agent host emits at most one Workspace entry, for the session's primary + /// working directory. Consumers MAY treat + /// `enablement[0]` as the decisive decision and + /// `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + /// absent or empty array means no explicit decision exists, so the + /// customization is enabled by default. + /// + /// Only the agent host publishes this; clients treat it as read-only provenance. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enablement: Option>, /// Icons for UI display. #[serde(default, skip_serializing_if = "Option::is_none")] pub icons: Option>, @@ -3453,7 +3581,8 @@ pub struct McpServerCustomization { /// out-of-band. #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, - /// Whether this MCP server is currently enabled. + /// Whether this MCP server is effectively enabled after resolving all scopes. + /// {@link CustomizationBase.enablement | `enablement`} records its inputs. pub enabled: bool, /// Current lifecycle state of the MCP server. pub state: McpServerState, @@ -4258,6 +4387,20 @@ pub struct ResourceChange { pub r#type: ResourceChangeType, } +// ─── Customization Enablement Union ─────────────────────────────────────── + +/// A single explicit customization enablement decision. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind")] +pub enum CustomizationEnablement { + #[serde(rename = "global")] + Global { enabled: bool }, + #[serde(rename = "workspace")] + Workspace { uri: Uri, enabled: bool }, + #[serde(rename = "session")] + Session { enabled: bool }, +} + /// Raw tool input represented inline or by content reference. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(untagged)] diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs index 027f48e0..2a6ef095 100644 --- a/clients/rust/crates/ahp/src/reducers.rs +++ b/clients/rust/crates/ahp/src/reducers.rs @@ -59,7 +59,8 @@ use ahp_types::actions::{ }; use ahp_types::state::{ ActiveTurn, AnnotationsState, ChangesetOperationStatus, ChangesetState, ChangesetStatus, - ChatInputRequest, ChatState, ChildCustomization, ConfirmationOption, Customization, ErrorInfo, + ChatInputRequest, ChatState, ChildCustomization, ConfirmationOption, Customization, + CustomizationEnablement, ErrorInfo, InputRequestResponsePart, McpServerStartingState, McpServerState, McpServerStoppedState, PendingMessage, PendingMessageKind, ResourceWatchState, ResponsePart, RootState, SessionInputRequest, SessionLifecycle, SessionState, SessionStatus, TerminalCommandPart, @@ -496,36 +497,76 @@ fn container_children_mut(c: &mut Customization) -> Option<&mut Vec bool { + match enablement.first() { + Some(CustomizationEnablement::Global { enabled }) => *enabled, + Some(CustomizationEnablement::Workspace { enabled, .. }) => *enabled, + Some(CustomizationEnablement::Session { enabled }) => *enabled, + None => true, + } +} + +fn apply_container_enablement(c: &mut Customization, enablement: &[CustomizationEnablement]) { + let enabled = effective_enablement(enablement); + let provenance = (!enablement.is_empty()).then(|| enablement.to_vec()); match c { - Customization::Plugin(p) => p.enabled = enabled, - Customization::Directory(d) => d.enabled = enabled, - Customization::McpServer(m) => m.enabled = enabled, + Customization::Plugin(p) => { + p.enabled = enabled; + p.enablement = provenance; + } + Customization::Directory(d) => { + d.enabled = enabled; + d.enablement = provenance; + } + Customization::McpServer(m) => { + m.enabled = enabled; + m.enablement = provenance; + } Customization::Unknown(_) => {} } } -fn set_child_enabled(c: &mut ChildCustomization, enabled: bool) { +fn apply_child_enablement(c: &mut ChildCustomization, enablement: &[CustomizationEnablement]) { + let enabled = effective_enablement(enablement); + let provenance = (!enablement.is_empty()).then(|| enablement.to_vec()); match c { - ChildCustomization::Agent(x) => x.enabled = Some(enabled), - ChildCustomization::Skill(x) => x.enabled = Some(enabled), - ChildCustomization::Prompt(x) => x.enabled = Some(enabled), - ChildCustomization::Rule(x) => x.enabled = Some(enabled), - ChildCustomization::Hook(x) => x.enabled = Some(enabled), - ChildCustomization::McpServer(x) => x.enabled = enabled, + ChildCustomization::Agent(x) => { + x.enabled = Some(enabled); + x.enablement = provenance; + } + ChildCustomization::Skill(x) => { + x.enabled = Some(enabled); + x.enablement = provenance; + } + ChildCustomization::Prompt(x) => { + x.enabled = Some(enabled); + x.enablement = provenance; + } + ChildCustomization::Rule(x) => { + x.enabled = Some(enabled); + x.enablement = provenance; + } + ChildCustomization::Hook(x) => { + x.enabled = Some(enabled); + x.enablement = provenance; + } + ChildCustomization::McpServer(x) => { + x.enabled = enabled; + x.enablement = provenance; + } ChildCustomization::Unknown(_) => {} } } -fn apply_toggle(list: &mut [Customization], id: &str, enabled: bool) -> bool { +fn apply_toggle(list: &mut [Customization], id: &str, enablement: &[CustomizationEnablement]) -> bool { if let Some(container) = list.iter_mut().find(|c| customization_id(c) == Some(id)) { - set_container_enabled(container, enabled); + apply_container_enablement(container, enablement); return true; } for container in list.iter_mut() { if let Some(children) = container_children_mut(container) { if let Some(child) = children.iter_mut().find(|c| child_id_of(c) == Some(id)) { - set_child_enabled(child, enabled); + apply_child_enablement(child, enablement); return true; } } @@ -841,7 +882,7 @@ pub fn apply_action_to_session(state: &mut SessionState, action: &StateAction) - let Some(list) = state.customizations.as_mut() else { return ReduceOutcome::NoOp; }; - if apply_toggle(list, &a.id, a.enabled) { + if apply_toggle(list, &a.id, &a.enablement) { ReduceOutcome::Applied } else { ReduceOutcome::NoOp diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift index da101cfd..d7220981 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift @@ -1354,19 +1354,19 @@ public struct SessionCustomizationsChangedAction: Codable, Sendable { public struct SessionCustomizationToggledAction: Codable, Sendable { public var type: ActionType - /// The id of the container or child to toggle. + /// The id of the container or child to update. public var id: String - /// Whether to enable or disable the targeted customization. - public var enabled: Bool + /// The complete set of explicit decisions, replacing any existing set. + public var enablement: [CustomizationEnablement] public init( type: ActionType, id: String, - enabled: Bool + enablement: [CustomizationEnablement] ) { self.type = type self.id = id - self.enabled = enabled + self.enablement = enablement } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index 7ec7dd9b..eca013fe 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -294,6 +294,13 @@ public enum CustomizationType: String, Codable, Sendable { case mcpServer = "mcpServer" } +/// Scope at which customization enablement is decided. +public enum CustomizationEnablementKind: String, Codable, Sendable { + case global = "global" + case workspace = "workspace" + case session = "session" +} + /// Discriminant values for {@link CustomizationLoadState}. public enum CustomizationLoadStatus: String, Codable, Sendable { case loading = "loading" @@ -3497,6 +3504,18 @@ public struct PluginCustomization: Codable, Sendable { public var uri: String /// Human-readable name. public var name: String + /// Explicit enablement decisions for this customization, one entry per scope + /// that has one. This is a wire contract: producers MUST publish entries + /// sorted by descending specificity (Session, Workspace, then Global). + /// The agent host emits at most one Workspace entry, for the session's primary + /// working directory. Consumers MAY treat + /// `enablement[0]` as the decisive decision and + /// `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + /// absent or empty array means no explicit decision exists, so the + /// customization is enabled by default. + /// + /// Only the agent host publishes this; clients treat it as read-only provenance. + public var enablement: [CustomizationEnablement]? /// Icons for UI display. public var icons: [Icon]? /// Optional span within {@link CustomizationBase.uri | `uri`} when this @@ -3537,6 +3556,7 @@ public struct PluginCustomization: Codable, Sendable { case id case uri case name + case enablement case icons case range case meta = "_meta" @@ -3552,6 +3572,7 @@ public struct PluginCustomization: Codable, Sendable { id: String, uri: String, name: String, + enablement: [CustomizationEnablement]? = nil, icons: [Icon]? = nil, range: TextRange? = nil, meta: [String: AnyCodable]? = nil, @@ -3565,6 +3586,7 @@ public struct PluginCustomization: Codable, Sendable { self.id = id self.uri = uri self.name = name + self.enablement = enablement self.icons = icons self.range = range self.meta = meta @@ -3592,6 +3614,18 @@ public struct ClientPluginCustomization: Codable, Sendable { public var uri: String /// Human-readable name. public var name: String + /// Explicit enablement decisions for this customization, one entry per scope + /// that has one. This is a wire contract: producers MUST publish entries + /// sorted by descending specificity (Session, Workspace, then Global). + /// The agent host emits at most one Workspace entry, for the session's primary + /// working directory. Consumers MAY treat + /// `enablement[0]` as the decisive decision and + /// `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + /// absent or empty array means no explicit decision exists, so the + /// customization is enabled by default. + /// + /// Only the agent host publishes this; clients treat it as read-only provenance. + public var enablement: [CustomizationEnablement]? /// Icons for UI display. public var icons: [Icon]? /// Optional span within {@link CustomizationBase.uri | `uri`} when this @@ -3634,6 +3668,7 @@ public struct ClientPluginCustomization: Codable, Sendable { case id case uri case name + case enablement case icons case range case meta = "_meta" @@ -3650,6 +3685,7 @@ public struct ClientPluginCustomization: Codable, Sendable { id: String, uri: String, name: String, + enablement: [CustomizationEnablement]? = nil, icons: [Icon]? = nil, range: TextRange? = nil, meta: [String: AnyCodable]? = nil, @@ -3664,6 +3700,7 @@ public struct ClientPluginCustomization: Codable, Sendable { self.id = id self.uri = uri self.name = name + self.enablement = enablement self.icons = icons self.range = range self.meta = meta @@ -3692,6 +3729,18 @@ public struct DirectoryCustomization: Codable, Sendable { public var uri: String /// Human-readable name. public var name: String + /// Explicit enablement decisions for this customization, one entry per scope + /// that has one. This is a wire contract: producers MUST publish entries + /// sorted by descending specificity (Session, Workspace, then Global). + /// The agent host emits at most one Workspace entry, for the session's primary + /// working directory. Consumers MAY treat + /// `enablement[0]` as the decisive decision and + /// `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + /// absent or empty array means no explicit decision exists, so the + /// customization is enabled by default. + /// + /// Only the agent host publishes this; clients treat it as read-only provenance. + public var enablement: [CustomizationEnablement]? /// Icons for UI display. public var icons: [Icon]? /// Optional span within {@link CustomizationBase.uri | `uri`} when this @@ -3729,6 +3778,7 @@ public struct DirectoryCustomization: Codable, Sendable { case id case uri case name + case enablement case icons case range case meta = "_meta" @@ -3745,6 +3795,7 @@ public struct DirectoryCustomization: Codable, Sendable { id: String, uri: String, name: String, + enablement: [CustomizationEnablement]? = nil, icons: [Icon]? = nil, range: TextRange? = nil, meta: [String: AnyCodable]? = nil, @@ -3759,6 +3810,7 @@ public struct DirectoryCustomization: Codable, Sendable { self.id = id self.uri = uri self.name = name + self.enablement = enablement self.icons = icons self.range = range self.meta = meta @@ -3787,6 +3839,18 @@ public struct AgentCustomization: Codable, Sendable { public var uri: String /// Human-readable name. public var name: String + /// Explicit enablement decisions for this customization, one entry per scope + /// that has one. This is a wire contract: producers MUST publish entries + /// sorted by descending specificity (Session, Workspace, then Global). + /// The agent host emits at most one Workspace entry, for the session's primary + /// working directory. Consumers MAY treat + /// `enablement[0]` as the decisive decision and + /// `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + /// absent or empty array means no explicit decision exists, so the + /// customization is enabled by default. + /// + /// Only the agent host publishes this; clients treat it as read-only provenance. + public var enablement: [CustomizationEnablement]? /// Icons for UI display. public var icons: [Icon]? /// Optional span within {@link CustomizationBase.uri | `uri`} when this @@ -3841,6 +3905,7 @@ public struct AgentCustomization: Codable, Sendable { case id case uri case name + case enablement case icons case range case meta = "_meta" @@ -3857,6 +3922,7 @@ public struct AgentCustomization: Codable, Sendable { id: String, uri: String, name: String, + enablement: [CustomizationEnablement]? = nil, icons: [Icon]? = nil, range: TextRange? = nil, meta: [String: AnyCodable]? = nil, @@ -3871,6 +3937,7 @@ public struct AgentCustomization: Codable, Sendable { self.id = id self.uri = uri self.name = name + self.enablement = enablement self.icons = icons self.range = range self.meta = meta @@ -3899,6 +3966,18 @@ public struct SkillCustomization: Codable, Sendable { public var uri: String /// Human-readable name. public var name: String + /// Explicit enablement decisions for this customization, one entry per scope + /// that has one. This is a wire contract: producers MUST publish entries + /// sorted by descending specificity (Session, Workspace, then Global). + /// The agent host emits at most one Workspace entry, for the session's primary + /// working directory. Consumers MAY treat + /// `enablement[0]` as the decisive decision and + /// `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + /// absent or empty array means no explicit decision exists, so the + /// customization is enabled by default. + /// + /// Only the agent host publishes this; clients treat it as read-only provenance. + public var enablement: [CustomizationEnablement]? /// Icons for UI display. public var icons: [Icon]? /// Optional span within {@link CustomizationBase.uri | `uri`} when this @@ -3941,6 +4020,7 @@ public struct SkillCustomization: Codable, Sendable { case id case uri case name + case enablement case icons case range case meta = "_meta" @@ -3955,6 +4035,7 @@ public struct SkillCustomization: Codable, Sendable { id: String, uri: String, name: String, + enablement: [CustomizationEnablement]? = nil, icons: [Icon]? = nil, range: TextRange? = nil, meta: [String: AnyCodable]? = nil, @@ -3967,6 +4048,7 @@ public struct SkillCustomization: Codable, Sendable { self.id = id self.uri = uri self.name = name + self.enablement = enablement self.icons = icons self.range = range self.meta = meta @@ -3993,6 +4075,18 @@ public struct PromptCustomization: Codable, Sendable { public var uri: String /// Human-readable name. public var name: String + /// Explicit enablement decisions for this customization, one entry per scope + /// that has one. This is a wire contract: producers MUST publish entries + /// sorted by descending specificity (Session, Workspace, then Global). + /// The agent host emits at most one Workspace entry, for the session's primary + /// working directory. Consumers MAY treat + /// `enablement[0]` as the decisive decision and + /// `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + /// absent or empty array means no explicit decision exists, so the + /// customization is enabled by default. + /// + /// Only the agent host publishes this; clients treat it as read-only provenance. + public var enablement: [CustomizationEnablement]? /// Icons for UI display. public var icons: [Icon]? /// Optional span within {@link CustomizationBase.uri | `uri`} when this @@ -4026,6 +4120,7 @@ public struct PromptCustomization: Codable, Sendable { case id case uri case name + case enablement case icons case range case meta = "_meta" @@ -4038,6 +4133,7 @@ public struct PromptCustomization: Codable, Sendable { id: String, uri: String, name: String, + enablement: [CustomizationEnablement]? = nil, icons: [Icon]? = nil, range: TextRange? = nil, meta: [String: AnyCodable]? = nil, @@ -4048,6 +4144,7 @@ public struct PromptCustomization: Codable, Sendable { self.id = id self.uri = uri self.name = name + self.enablement = enablement self.icons = icons self.range = range self.meta = meta @@ -4072,6 +4169,18 @@ public struct RuleCustomization: Codable, Sendable { public var uri: String /// Human-readable name. public var name: String + /// Explicit enablement decisions for this customization, one entry per scope + /// that has one. This is a wire contract: producers MUST publish entries + /// sorted by descending specificity (Session, Workspace, then Global). + /// The agent host emits at most one Workspace entry, for the session's primary + /// working directory. Consumers MAY treat + /// `enablement[0]` as the decisive decision and + /// `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + /// absent or empty array means no explicit decision exists, so the + /// customization is enabled by default. + /// + /// Only the agent host publishes this; clients treat it as read-only provenance. + public var enablement: [CustomizationEnablement]? /// Icons for UI display. public var icons: [Icon]? /// Optional span within {@link CustomizationBase.uri | `uri`} when this @@ -4112,6 +4221,7 @@ public struct RuleCustomization: Codable, Sendable { case id case uri case name + case enablement case icons case range case meta = "_meta" @@ -4126,6 +4236,7 @@ public struct RuleCustomization: Codable, Sendable { id: String, uri: String, name: String, + enablement: [CustomizationEnablement]? = nil, icons: [Icon]? = nil, range: TextRange? = nil, meta: [String: AnyCodable]? = nil, @@ -4138,6 +4249,7 @@ public struct RuleCustomization: Codable, Sendable { self.id = id self.uri = uri self.name = name + self.enablement = enablement self.icons = icons self.range = range self.meta = meta @@ -4164,6 +4276,18 @@ public struct HookCustomization: Codable, Sendable { public var uri: String /// Human-readable name. public var name: String + /// Explicit enablement decisions for this customization, one entry per scope + /// that has one. This is a wire contract: producers MUST publish entries + /// sorted by descending specificity (Session, Workspace, then Global). + /// The agent host emits at most one Workspace entry, for the session's primary + /// working directory. Consumers MAY treat + /// `enablement[0]` as the decisive decision and + /// `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + /// absent or empty array means no explicit decision exists, so the + /// customization is enabled by default. + /// + /// Only the agent host publishes this; clients treat it as read-only provenance. + public var enablement: [CustomizationEnablement]? /// Icons for UI display. public var icons: [Icon]? /// Optional span within {@link CustomizationBase.uri | `uri`} when this @@ -4195,6 +4319,7 @@ public struct HookCustomization: Codable, Sendable { case id case uri case name + case enablement case icons case range case meta = "_meta" @@ -4206,6 +4331,7 @@ public struct HookCustomization: Codable, Sendable { id: String, uri: String, name: String, + enablement: [CustomizationEnablement]? = nil, icons: [Icon]? = nil, range: TextRange? = nil, meta: [String: AnyCodable]? = nil, @@ -4215,6 +4341,7 @@ public struct HookCustomization: Codable, Sendable { self.id = id self.uri = uri self.name = name + self.enablement = enablement self.icons = icons self.range = range self.meta = meta @@ -4238,6 +4365,18 @@ public struct McpServerCustomization: Codable, Sendable { public var uri: String /// Human-readable name. public var name: String + /// Explicit enablement decisions for this customization, one entry per scope + /// that has one. This is a wire contract: producers MUST publish entries + /// sorted by descending specificity (Session, Workspace, then Global). + /// The agent host emits at most one Workspace entry, for the session's primary + /// working directory. Consumers MAY treat + /// `enablement[0]` as the decisive decision and + /// `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + /// absent or empty array means no explicit decision exists, so the + /// customization is enabled by default. + /// + /// Only the agent host publishes this; clients treat it as read-only provenance. + public var enablement: [CustomizationEnablement]? /// Icons for UI display. public var icons: [Icon]? /// Optional span within {@link CustomizationBase.uri | `uri`} when this @@ -4252,7 +4391,8 @@ public struct McpServerCustomization: Codable, Sendable { /// out-of-band. public var meta: [String: AnyCodable]? public var type: CustomizationType - /// Whether this MCP server is currently enabled. + /// Whether this MCP server is effectively enabled after resolving all scopes. + /// {@link CustomizationBase.enablement | `enablement`} records its inputs. public var enabled: Bool /// Current lifecycle state of the MCP server. public var state: McpServerState @@ -4280,6 +4420,7 @@ public struct McpServerCustomization: Codable, Sendable { case id case uri case name + case enablement case icons case range case meta = "_meta" @@ -4294,6 +4435,7 @@ public struct McpServerCustomization: Codable, Sendable { id: String, uri: String, name: String, + enablement: [CustomizationEnablement]? = nil, icons: [Icon]? = nil, range: TextRange? = nil, meta: [String: AnyCodable]? = nil, @@ -4306,6 +4448,7 @@ public struct McpServerCustomization: Codable, Sendable { self.id = id self.uri = uri self.name = name + self.enablement = enablement self.icons = icons self.range = range self.meta = meta @@ -5227,6 +5370,70 @@ public struct ResourceChange: Codable, Sendable { } } +// MARK: - Customization Enablement Union + +/// A single explicit customization enablement decision. +public enum CustomizationEnablement: Codable, Sendable { + case global(CustomizationEnablementGlobal) + case workspace(CustomizationEnablementWorkspace) + case session(CustomizationEnablementSession) + + private enum DiscriminantKey: String, CodingKey { + case kind + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DiscriminantKey.self) + switch try container.decode(String.self, forKey: .kind) { + case "global": + self = .global(try CustomizationEnablementGlobal(from: decoder)) + case "workspace": + self = .workspace(try CustomizationEnablementWorkspace(from: decoder)) + case "session": + self = .session(try CustomizationEnablementSession(from: decoder)) + default: + throw DecodingError.dataCorruptedError(forKey: .kind, in: container, debugDescription: "Unknown CustomizationEnablement kind") + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .global(let value): try value.encode(to: encoder) + case .workspace(let value): try value.encode(to: encoder) + case .session(let value): try value.encode(to: encoder) + } + } +} + +public struct CustomizationEnablementGlobal: Codable, Sendable { + public var kind: String = "global" + public var enabled: Bool + + public init(enabled: Bool) { + self.enabled = enabled + } +} + +public struct CustomizationEnablementWorkspace: Codable, Sendable { + public var kind: String = "workspace" + public var uri: URI + public var enabled: Bool + + public init(uri: URI, enabled: Bool) { + self.uri = uri + self.enabled = enabled + } +} + +public struct CustomizationEnablementSession: Codable, Sendable { + public var kind: String = "session" + public var enabled: Bool + + public init(enabled: Bool) { + self.enabled = enabled + } +} + // MARK: - Tool Input /// Raw tool input represented inline or by content reference. diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/NativeReducer.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/NativeReducer.swift index e3e10ef6..cca688da 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/NativeReducer.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/NativeReducer.swift @@ -216,16 +216,30 @@ func setCustomizationChildren(_ c: inout Customization, _ children: [ChildCustom } } -func setCustomizationEnabled(_ c: inout Customization, _ enabled: Bool) { +func effectiveEnablement(_ enablement: [CustomizationEnablement]) -> Bool { + guard let decision = enablement.first else { return true } + switch decision { + case .global(let value): return value.enabled + case .workspace(let value): return value.enabled + case .session(let value): return value.enabled + } +} + +func applyCustomizationEnablement(_ c: inout Customization, _ enablement: [CustomizationEnablement]) { + let enabled = effectiveEnablement(enablement) + let provenance = enablement.isEmpty ? nil : enablement switch c { case .plugin(var p): p.enabled = enabled + p.enablement = provenance c = .plugin(p) case .directory(var d): d.enabled = enabled + d.enablement = provenance c = .directory(d) case .mcpServer(var m): m.enabled = enabled + m.enablement = provenance c = .mcpServer(m) // Unknown/future customization: opaque payload, nothing to mutate. case .unknown: @@ -233,25 +247,33 @@ func setCustomizationEnabled(_ c: inout Customization, _ enabled: Bool) { } } -func setChildCustomizationEnabled(_ c: inout ChildCustomization, _ enabled: Bool) { +func applyChildCustomizationEnablement(_ c: inout ChildCustomization, _ enablement: [CustomizationEnablement]) { + let enabled = effectiveEnablement(enablement) + let provenance = enablement.isEmpty ? nil : enablement switch c { case .agent(var x): x.enabled = enabled + x.enablement = provenance c = .agent(x) case .skill(var x): x.enabled = enabled + x.enablement = provenance c = .skill(x) case .prompt(var x): x.enabled = enabled + x.enablement = provenance c = .prompt(x) case .rule(var x): x.enabled = enabled + x.enablement = provenance c = .rule(x) case .hook(var x): x.enabled = enabled + x.enablement = provenance c = .hook(x) case .mcpServer(var x): x.enabled = enabled + x.enablement = provenance c = .mcpServer(x) // Unknown/future child customization: opaque payload, nothing to mutate. case .unknown: @@ -259,11 +281,11 @@ func setChildCustomizationEnabled(_ c: inout ChildCustomization, _ enabled: Bool } } -func toggleCustomization(in list: inout [Customization], id: String, enabled: Bool) -> Bool { +func toggleCustomization(in list: inout [Customization], id: String, enablement: [CustomizationEnablement]) -> Bool { for i in list.indices { if customizationId(list[i]) == id { var entry = list[i] - setCustomizationEnabled(&entry, enabled) + applyCustomizationEnablement(&entry, enablement) list[i] = entry return true } @@ -273,7 +295,7 @@ func toggleCustomization(in list: inout [Customization], id: String, enabled: Bo guard var children = customizationChildren(container) else { continue } guard let childIdx = children.firstIndex(where: { childId($0) == id }) else { continue } var child = children[childIdx] - setChildCustomizationEnabled(&child, enabled) + applyChildCustomizationEnablement(&child, enablement) children[childIdx] = child setCustomizationChildren(&container, children) list[containerIdx] = container diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift index 4dfa1c2c..9e46edc9 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift @@ -817,7 +817,7 @@ public func sessionReducer(state: SessionState, action: StateAction) -> SessionS case .sessionCustomizationToggled(let a): guard var list = state.customizations else { return state } - guard toggleCustomization(in: &list, id: a.id, enabled: a.enabled) else { return state } + guard toggleCustomization(in: &list, id: a.id, enablement: a.enablement) else { return state } var next = state next.customizations = list return next diff --git a/docs/.changes/20260804-scoped-customization-enablement.json b/docs/.changes/20260804-scoped-customization-enablement.json new file mode 100644 index 00000000..74cb1816 --- /dev/null +++ b/docs/.changes/20260804-scoped-customization-enablement.json @@ -0,0 +1,4 @@ +{ + "type": "changed", + "message": "`Customization` enablement now carries scoped host-published provenance, and `session/customizationToggled` replaces its complete decision set." +} diff --git a/docs/guide/actions.md b/docs/guide/actions.md index b863ff52..db0ad6a8 100644 --- a/docs/guide/actions.md +++ b/docs/guide/actions.md @@ -125,7 +125,7 @@ See [Elicitation](/guide/elicitation) for the request lifecycle. | Type | Client-dispatchable? | When | |---|---|---| | `session/customizationsChanged` | No | Server replaced the session's top-level customization list (full replacement) | -| `session/customizationToggled` | **Yes** | Client toggled a container or child customization on or off by id | +| `session/customizationToggled` | **Yes** | Client replaced a customization's explicit enablement decisions by id | | `session/customizationUpdated` | No | Server upserted a top-level container (plugin or directory) by id (full-entry replacement, including children) | | `session/customizationRemoved` | No | Server removed a customization by id (containers cascade to children) | @@ -193,7 +193,7 @@ The client applies the action **optimistically** to its local state before sendi | `chat/pendingMessageSet` | Stores a steering or queued message (upsert); if queued and idle, auto-starts a turn | | `chat/pendingMessageRemoved` | Cancels a pending message before it is consumed | | `chat/queuedMessagesReordered` | Reorders queued messages; unknown IDs ignored, unmentioned messages kept at end | -| `session/customizationToggled` | Toggles a container or child customization on or off by id | +| `session/customizationToggled` | Replaces a customization's explicit enablement decisions by id | | `session/isReadChanged` | Marks the session as read or unread | | `session/isArchivedChanged` | Archives or unarchives the session | diff --git a/docs/guide/customizations.md b/docs/guide/customizations.md index 4b51c1a6..c4412829 100644 --- a/docs/guide/customizations.md +++ b/docs/guide/customizations.md @@ -5,7 +5,7 @@ Customizations extend agent sessions with additional capabilities — agents, sk - **Top-level entries are typically containers**: a `PluginCustomization` (an [Open Plugins](https://open-plugins.com/) package) or a `DirectoryCustomization` (a directory the host watches on disk). The host MAY also surface a bare `McpServerCustomization` at the top level (for example, a globally-configured MCP server that isn't bundled in a plugin). - **Other children live inside a container**: `AgentCustomization`, `SkillCustomization`, `PromptCustomization`, `RuleCustomization`, `HookCustomization`, `McpServerCustomization`. MCP servers can therefore appear in either position. -The agent host is authoritative on the effective tree. Clients publish plugins, the host expands them into children, and the host owns disk-backed directories and bare top-level MCP servers. +The agent host is authoritative on the effective tree and its enablement. Clients publish plugins, the host expands them into children, and the host owns disk-backed directories and bare top-level MCP servers. For MCP-specific behaviour (server lifecycle, authentication, App support), see [MCP Servers](/guide/mcp). @@ -58,6 +58,7 @@ PluginCustomization { name: string icons?: Icon[] enabled: boolean + enablement?: CustomizationEnablement[] // host-published explicit decisions clientId?: string // set when published by a client load?: CustomizationLoadState // host-reported parse/load state children?: ChildCustomization[] @@ -70,6 +71,7 @@ DirectoryCustomization { name: string icons?: Icon[] enabled: boolean + enablement?: CustomizationEnablement[] clientId?: string load?: CustomizationLoadState children?: ChildCustomization[] @@ -108,7 +110,7 @@ stateDiagram-v2 ## Children -Every child carries the same base fields (`id`, `uri`, `name`, optional `icons`) plus an `enabled` flag — optional for the five leaf children (absent means enabled) and always present on an `McpServerCustomization`. Children are leaf nodes — no further nesting — and their parent is implied by which container holds them in its `children` array. A child's `enabled` is independent of its container's. Children have no `clientId`: client provenance lives on the container since clients can only contribute containers, not individual children. +Every child carries the same base fields (`id`, `uri`, `name`, optional `icons`, optional `enablement`) plus an `enabled` flag — optional for the five leaf children (absent means enabled) and always present on an `McpServerCustomization`. Children are leaf nodes — no further nesting — and their parent is implied by which container holds them in its `children` array. A child's `enabled` is independent of its container's. Children have no `clientId`: client provenance lives on the container since clients can only contribute containers, not individual children. Each child type carries optional metadata sourced from its [Open Plugins](https://open-plugins.com/plugin-builders/specification.md) component definition (typically the file's YAML frontmatter): @@ -133,19 +135,51 @@ state.customizations .filter(c => c.type === CustomizationType.Agent) ``` +## Enablement + +Every customization may carry an `enablement` array of explicit decisions: + +```typescript +CustomizationEnablement = + | { kind: 'global'; enabled: boolean } + | { kind: 'workspace'; uri: URI; enabled: boolean } + | { kind: 'session'; enabled: boolean } +``` + +The agent host is the only publisher of this read-only provenance. Producers +MUST publish the entries in descending specificity: session, workspace, then +global. The host emits at most one workspace decision, for the session's +primary working directory. Consumers MAY use `enablement[0]` as the decisive +decision, with `enablement?.[0]?.enabled ?? true` as the effective value. An +absent or empty array has no explicit decision and means enabled by default. + +`enabled` remains the display-ready, effective value. Both containers and +children carry it; a child's final state is +`container.enabled && (child.enabled ?? true)`, so disabling a container still +disables all of its children. + ## Toggling -Any client can enable or disable any customization by dispatching `session/customizationToggled` with that entry's `id`: +Any client can request an enablement update with +`session/customizationToggled`. It carries the complete set of explicit +decisions for the entry: ```typescript { type: 'session/customizationToggled' id: string // any customization id - enabled: boolean + enablement: CustomizationEnablement[] } ``` -Both containers and children carry an `enabled` flag. The reducer matches `id` against every top-level customization first — plugins, directories, and bare top-level MCP servers — then against the children inside every container, and sets that entry's `enabled`. A child's effective state is `container.enabled && (child.enabled ?? true)`, so disabling a container disables all of its children regardless of each child's own flag, and a child toggle only takes effect while its container is enabled. The action is a no-op if no customization has that id. +The action replaces the entry's array wholesale rather than merging a single +scope. A caller changing one scope must include every decision it intends to +preserve. The reducer matches `id` against every top-level customization first +— plugins, directories, and bare top-level MCP servers — then against the +children inside every container, replaces the matched entry's `enablement`, +and recomputes its `enabled` from the first decision. An empty array clears +the explicit provenance and restores the default enabled value. The action is +a no-op if no customization has that id. ```mermaid sequenceDiagram @@ -154,7 +188,7 @@ sequenceDiagram Note over Server: customizations: [Plugin A (enabled), Plugin B (enabled)] - Client->>Server: customizationToggled (id: plugin-a, enabled: false) + Client->>Server: customizationToggled (id: plugin-a, enablement: [session: false]) Server->>Client: action echoed Note over Server: customizations: [Plugin A (disabled), Plugin B (enabled)] ``` diff --git a/schema/actions.schema.json b/schema/actions.schema.json index fffbb460..67725d08 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -495,24 +495,27 @@ }, "SessionCustomizationToggledAction": { "type": "object", - "description": "A client toggled a customization on or off.\n\nMatches `id` against every top-level customization first — a plugin or\ndirectory container, or a bare top-level MCP server — then against the\nchildren inside each container (a skill, agent, or other entry), and\nsets the matched entry's `enabled` flag. Disabling a container still\ndisables all of its children — the effective state of a child is\n`container.enabled && (child.enabled ?? true)` — so toggling a child\nonly matters while its container is enabled. Is a no-op when no\ncustomization has the given `id`.", + "description": "A client toggled a customization on or off.\n\nMatches `id` against every top-level customization first — a plugin or\ndirectory container, or a bare top-level MCP server — then against the\nchildren inside each container (a skill, agent, or other entry), and\nsets the matched entry's `enabled` flag. Disabling a container still\ndisables all of its children — the effective state of a child is\n`container.enabled && (child.enabled ?? true)` — so toggling a child\nonly matters while its container is enabled. Is a no-op when no\ncustomization has the given `id`.\n\nThe `enablement` array completely replaces all explicit decisions. A caller\nchanging one scope must include every decision it intends to preserve.", "properties": { "type": { "const": "session/customizationToggled" }, "id": { "type": "string", - "description": "The id of the container or child to toggle." + "description": "The id of the container or child to update." }, - "enabled": { - "type": "boolean", - "description": "Whether to enable or disable the targeted customization." + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "The complete set of explicit decisions, replacing any existing set." } }, "required": [ "type", "id", - "enabled" + "enablement" ] }, "SessionCustomizationUpdatedAction": { @@ -3535,6 +3538,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -3632,6 +3642,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -3691,6 +3708,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -3758,6 +3782,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -3829,6 +3860,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -3902,6 +3940,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -3945,6 +3990,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -4015,6 +4067,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -4074,6 +4133,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -4125,6 +4191,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -4187,6 +4260,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -4234,6 +4314,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -4255,7 +4342,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this MCP server is currently enabled." + "description": "Whether this MCP server is effectively enabled after resolving all scopes.\n{@link CustomizationBase.enablement | `enablement`} records its inputs." }, "state": { "$ref": "#/$defs/McpServerState", @@ -7127,6 +7214,60 @@ ], "description": "One outstanding piece of input a session is blocked on, aggregated across all\nchats in {@link SessionState.inputNeeded}.\n\nEach entry is self-sufficient: it carries the owning\n{@link SessionInputRequestBase.chat | `chat`} URI plus every identifier needed\nto construct the response, so a client can answer by dispatching the ordinary\n`chat/*` action (`chat/inputCompleted`, `chat/toolCallConfirmed`,\n`chat/toolCallComplete`, …) to that chat's channel **without having subscribed\nto the chat** — except {@link SessionToolAuthenticationRequest}, which is\nresolved via the `authenticate` command instead. The host removes the entry\nwith `session/inputNeededRemoved` once the underlying request resolves." }, + "CustomizationEnablement": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "const": "global" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "kind", + "enabled" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "workspace" + }, + "uri": { + "$ref": "#/$defs/URI" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "kind", + "uri", + "enabled" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "session" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "kind", + "enabled" + ] + } + ], + "description": "A single explicit enablement decision." + }, "ChildCustomizationType": { "oneOf": [ { diff --git a/schema/commands.schema.json b/schema/commands.schema.json index e65cb0f5..1ab5db2a 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -2844,6 +2844,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -2941,6 +2948,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -3000,6 +3014,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -3067,6 +3088,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -3138,6 +3166,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -3211,6 +3246,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -3254,6 +3296,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -3324,6 +3373,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -3383,6 +3439,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -3434,6 +3497,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -3496,6 +3566,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -3543,6 +3620,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -3564,7 +3648,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this MCP server is currently enabled." + "description": "Whether this MCP server is effectively enabled after resolving all scopes.\n{@link CustomizationBase.enablement | `enablement`} records its inputs." }, "state": { "$ref": "#/$defs/McpServerState", @@ -6873,24 +6957,27 @@ }, "SessionCustomizationToggledAction": { "type": "object", - "description": "A client toggled a customization on or off.\n\nMatches `id` against every top-level customization first — a plugin or\ndirectory container, or a bare top-level MCP server — then against the\nchildren inside each container (a skill, agent, or other entry), and\nsets the matched entry's `enabled` flag. Disabling a container still\ndisables all of its children — the effective state of a child is\n`container.enabled && (child.enabled ?? true)` — so toggling a child\nonly matters while its container is enabled. Is a no-op when no\ncustomization has the given `id`.", + "description": "A client toggled a customization on or off.\n\nMatches `id` against every top-level customization first — a plugin or\ndirectory container, or a bare top-level MCP server — then against the\nchildren inside each container (a skill, agent, or other entry), and\nsets the matched entry's `enabled` flag. Disabling a container still\ndisables all of its children — the effective state of a child is\n`container.enabled && (child.enabled ?? true)` — so toggling a child\nonly matters while its container is enabled. Is a no-op when no\ncustomization has the given `id`.\n\nThe `enablement` array completely replaces all explicit decisions. A caller\nchanging one scope must include every decision it intends to preserve.", "properties": { "type": { "const": "session/customizationToggled" }, "id": { "type": "string", - "description": "The id of the container or child to toggle." + "description": "The id of the container or child to update." }, - "enabled": { - "type": "boolean", - "description": "Whether to enable or disable the targeted customization." + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "The complete set of explicit decisions, replacing any existing set." } }, "required": [ "type", "id", - "enabled" + "enablement" ] }, "SessionCustomizationUpdatedAction": { @@ -8991,6 +9078,60 @@ ], "description": "Discriminated union of all tool call lifecycle states.\n\nSee the [state model guide](/guide/state-model.html#tool-call-lifecycle)\nfor the full state machine diagram." }, + "CustomizationEnablement": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "const": "global" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "kind", + "enabled" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "workspace" + }, + "uri": { + "$ref": "#/$defs/URI" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "kind", + "uri", + "enabled" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "session" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "kind", + "enabled" + ] + } + ], + "description": "A single explicit enablement decision." + }, "CustomizationLoadState": { "oneOf": [ { diff --git a/schema/errors.schema.json b/schema/errors.schema.json index b1b9745f..a4c92125 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -1440,6 +1440,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -1537,6 +1544,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -1596,6 +1610,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -1663,6 +1684,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -1734,6 +1762,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -1807,6 +1842,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -1850,6 +1892,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -1920,6 +1969,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -1979,6 +2035,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -2030,6 +2093,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -2092,6 +2162,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -2139,6 +2216,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -2160,7 +2244,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this MCP server is currently enabled." + "description": "Whether this MCP server is effectively enabled after resolving all scopes.\n{@link CustomizationBase.enablement | `enablement`} records its inputs." }, "state": { "$ref": "#/$defs/McpServerState", @@ -6591,6 +6675,60 @@ ], "description": "Discriminated union of all tool call lifecycle states.\n\nSee the [state model guide](/guide/state-model.html#tool-call-lifecycle)\nfor the full state machine diagram." }, + "CustomizationEnablement": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "const": "global" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "kind", + "enabled" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "workspace" + }, + "uri": { + "$ref": "#/$defs/URI" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "kind", + "uri", + "enabled" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "session" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "kind", + "enabled" + ] + } + ], + "description": "A single explicit enablement decision." + }, "CustomizationLoadState": { "oneOf": [ { @@ -7831,24 +7969,27 @@ }, "SessionCustomizationToggledAction": { "type": "object", - "description": "A client toggled a customization on or off.\n\nMatches `id` against every top-level customization first — a plugin or\ndirectory container, or a bare top-level MCP server — then against the\nchildren inside each container (a skill, agent, or other entry), and\nsets the matched entry's `enabled` flag. Disabling a container still\ndisables all of its children — the effective state of a child is\n`container.enabled && (child.enabled ?? true)` — so toggling a child\nonly matters while its container is enabled. Is a no-op when no\ncustomization has the given `id`.", + "description": "A client toggled a customization on or off.\n\nMatches `id` against every top-level customization first — a plugin or\ndirectory container, or a bare top-level MCP server — then against the\nchildren inside each container (a skill, agent, or other entry), and\nsets the matched entry's `enabled` flag. Disabling a container still\ndisables all of its children — the effective state of a child is\n`container.enabled && (child.enabled ?? true)` — so toggling a child\nonly matters while its container is enabled. Is a no-op when no\ncustomization has the given `id`.\n\nThe `enablement` array completely replaces all explicit decisions. A caller\nchanging one scope must include every decision it intends to preserve.", "properties": { "type": { "const": "session/customizationToggled" }, "id": { "type": "string", - "description": "The id of the container or child to toggle." + "description": "The id of the container or child to update." }, - "enabled": { - "type": "boolean", - "description": "Whether to enable or disable the targeted customization." + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "The complete set of explicit decisions, replacing any existing set." } }, "required": [ "type", "id", - "enabled" + "enablement" ] }, "SessionCustomizationUpdatedAction": { diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index ab95e4b4..34a1683c 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -1603,6 +1603,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -1700,6 +1707,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -1759,6 +1773,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -1826,6 +1847,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -1897,6 +1925,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -1970,6 +2005,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -2013,6 +2055,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -2083,6 +2132,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -2142,6 +2198,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -2193,6 +2256,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -2255,6 +2325,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -2302,6 +2379,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -2323,7 +2407,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this MCP server is currently enabled." + "description": "Whether this MCP server is effectively enabled after resolving all scopes.\n{@link CustomizationBase.enablement | `enablement`} records its inputs." }, "state": { "$ref": "#/$defs/McpServerState", @@ -5269,6 +5353,60 @@ ], "description": "Discriminated union of all tool call lifecycle states.\n\nSee the [state model guide](/guide/state-model.html#tool-call-lifecycle)\nfor the full state machine diagram." }, + "CustomizationEnablement": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "const": "global" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "kind", + "enabled" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "workspace" + }, + "uri": { + "$ref": "#/$defs/URI" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "kind", + "uri", + "enabled" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "session" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "kind", + "enabled" + ] + } + ], + "description": "A single explicit enablement decision." + }, "CustomizationLoadState": { "oneOf": [ { diff --git a/schema/state.schema.json b/schema/state.schema.json index d594f6b4..077f9870 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -1351,6 +1351,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -1448,6 +1455,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -1507,6 +1521,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -1574,6 +1595,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -1645,6 +1673,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -1718,6 +1753,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -1761,6 +1803,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -1831,6 +1880,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -1890,6 +1946,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -1941,6 +2004,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -2003,6 +2073,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -2050,6 +2127,13 @@ "type": "string", "description": "Human-readable name." }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nOnly the agent host publishes this; clients treat it as read-only provenance." + }, "icons": { "type": "array", "items": { @@ -2071,7 +2155,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this MCP server is currently enabled." + "description": "Whether this MCP server is effectively enabled after resolving all scopes.\n{@link CustomizationBase.enablement | `enablement`} records its inputs." }, "state": { "$ref": "#/$defs/McpServerState", @@ -4943,6 +5027,60 @@ ], "description": "One outstanding piece of input a session is blocked on, aggregated across all\nchats in {@link SessionState.inputNeeded}.\n\nEach entry is self-sufficient: it carries the owning\n{@link SessionInputRequestBase.chat | `chat`} URI plus every identifier needed\nto construct the response, so a client can answer by dispatching the ordinary\n`chat/*` action (`chat/inputCompleted`, `chat/toolCallConfirmed`,\n`chat/toolCallComplete`, …) to that chat's channel **without having subscribed\nto the chat** — except {@link SessionToolAuthenticationRequest}, which is\nresolved via the `authenticate` command instead. The host removes the entry\nwith `session/inputNeededRemoved` once the underlying request resolves." }, + "CustomizationEnablement": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "const": "global" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "kind", + "enabled" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "workspace" + }, + "uri": { + "$ref": "#/$defs/URI" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "kind", + "uri", + "enabled" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "session" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "kind", + "enabled" + ] + } + ], + "description": "A single explicit enablement decision." + }, "ChildCustomizationType": { "oneOf": [ { diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index 34e8eabd..63b508b2 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -695,7 +695,7 @@ const STATE_ENUMS = [ 'ToolCallRiskAssessmentStatus', 'ToolCallCancellationReason', 'ConfirmationOptionKind', 'ToolCallContributorKind', - 'ToolResultContentType', 'CustomizationType', 'CustomizationLoadStatus', 'TerminalClaimKind', + 'ToolResultContentType', 'CustomizationType', 'CustomizationEnablementKind', 'CustomizationLoadStatus', 'TerminalClaimKind', 'McpServerStatus', 'McpAuthRequiredReason', 'ChangesetStatus', 'ChangesetOperationStatus', 'ChangesetOperationScope', 'ResourceChangeType', ]; @@ -1315,6 +1315,11 @@ function generateStateFile(project: Project): string { } } + lines.push('// ─── Customization Enablement Union ───────────────────────────────────────'); + lines.push(''); + lines.push(generateCustomizationEnablementGo()); + lines.push(''); + lines.push(generateToolInput()); lines.push(''); @@ -1610,6 +1615,74 @@ const CHAT_SOURCE_UNION: UnionConfig = { ], }; +function generateCustomizationEnablementGo(): string { + return `// CustomizationEnablement is a single explicit customization enablement decision. +type CustomizationEnablement struct { +\tValue isCustomizationEnablement +} + +type isCustomizationEnablement interface{ isCustomizationEnablement() } + +type CustomizationEnablementGlobal struct { +\tKind string \`json:"kind"\` +\tEnabled bool \`json:"enabled"\` +} + +func (*CustomizationEnablementGlobal) isCustomizationEnablement() {} + +type CustomizationEnablementWorkspace struct { +\tKind string \`json:"kind"\` +\tURI URI \`json:"uri"\` +\tEnabled bool \`json:"enabled"\` +} + +func (*CustomizationEnablementWorkspace) isCustomizationEnablement() {} + +type CustomizationEnablementSession struct { +\tKind string \`json:"kind"\` +\tEnabled bool \`json:"enabled"\` +} + +func (*CustomizationEnablementSession) isCustomizationEnablement() {} + +func (e *CustomizationEnablement) UnmarshalJSON(data []byte) error { +\tdisc, _, err := readDiscriminator(data, "kind") +\tif err != nil { +\t\treturn err +\t} +\tswitch disc { +\tcase "global": +\t\tvar value CustomizationEnablementGlobal +\t\tif err := json.Unmarshal(data, &value); err != nil { +\t\t\treturn err +\t\t} +\t\te.Value = &value +\tcase "workspace": +\t\tvar value CustomizationEnablementWorkspace +\t\tif err := json.Unmarshal(data, &value); err != nil { +\t\t\treturn err +\t\t} +\t\te.Value = &value +\tcase "session": +\t\tvar value CustomizationEnablementSession +\t\tif err := json.Unmarshal(data, &value); err != nil { +\t\t\treturn err +\t\t} +\t\te.Value = &value +\tdefault: +\t\treturn &json.UnmarshalTypeError{Value: "CustomizationEnablement"} +\t} +\treturn nil +} + +func (e CustomizationEnablement) MarshalJSON() ([]byte, error) { +\tif e.Value == nil { +\t\treturn []byte("null"), nil +\t} +\treturn json.Marshal(e.Value) +}`; +} + function generateChangesetOperationTargetGo(): string { return `// ChangesetOperationTarget identifies the file or range a // ChangesetOperation should act on. @@ -2095,6 +2168,7 @@ function checkExhaustiveness(project: Project): void { 'AhpErrorCodeWithData', 'JsonRpcErrorCode', 'ChangesetOperationTarget', + 'CustomizationEnablement', ]); const missing = [...imported].filter((n) => !coveredByLists.has(n) && !knownSpecial.has(n)); diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index 81a192de..2c2f17cb 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -900,7 +900,7 @@ const STATE_ENUMS = [ 'ToolCallRiskAssessmentStatus', 'ToolCallCancellationReason', 'ConfirmationOptionKind', 'ToolCallContributorKind', - 'ToolResultContentType', 'CustomizationType', 'CustomizationLoadStatus', 'TerminalClaimKind', + 'ToolResultContentType', 'CustomizationType', 'CustomizationEnablementKind', 'CustomizationLoadStatus', 'TerminalClaimKind', 'McpServerStatus', 'McpAuthRequiredReason', 'ChangesetStatus', 'ChangesetOperationStatus', 'ChangesetOperationScope', 'ResourceChangeType', ]; @@ -1256,6 +1256,11 @@ function generateStateFile(project: Project): string { } } + lines.push('// ─── Customization Enablement Union ─────────────────────────────────────'); + lines.push(''); + lines.push(generateCustomizationEnablementKotlin()); + lines.push(''); + lines.push('// ─── Tool Input ──────────────────────────────────────────────────────────────'); lines.push(''); lines.push(generateToolInput()); @@ -1600,6 +1605,76 @@ const CHAT_SOURCE_UNION: UnionConfig = { ], }; +function generateCustomizationEnablementKotlin(): string { + return `/** + * A single explicit customization enablement decision. + */ +@Serializable(with = CustomizationEnablementSerializer::class) +sealed interface CustomizationEnablement { + @JvmInline value class Global(val value: CustomizationEnablementGlobal) : CustomizationEnablement + @JvmInline value class Workspace(val value: CustomizationEnablementWorkspace) : CustomizationEnablement + @JvmInline value class Session(val value: CustomizationEnablementSession) : CustomizationEnablement +} + +@Serializable +data class CustomizationEnablementGlobal( + val enabled: Boolean, + val kind: String = "global", +) + +@Serializable +data class CustomizationEnablementWorkspace( + val uri: URI, + val enabled: Boolean, + val kind: String = "workspace", +) + +@Serializable +data class CustomizationEnablementSession( + val enabled: Boolean, + val kind: String = "session", +) + +internal object CustomizationEnablementSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("CustomizationEnablement") + + override fun deserialize(decoder: Decoder): CustomizationEnablement { + val input = decoder as? JsonDecoder + ?: error("CustomizationEnablement can only be deserialized from JSON") + val element = input.decodeJsonElement() + val obj = element as? JsonObject + ?: error("Expected JsonObject for CustomizationEnablement") + return when ((obj["kind"] as? JsonPrimitive)?.contentOrNull) { + "global" -> CustomizationEnablement.Global( + input.json.decodeFromJsonElement(CustomizationEnablementGlobal.serializer(), element), + ) + "workspace" -> CustomizationEnablement.Workspace( + input.json.decodeFromJsonElement(CustomizationEnablementWorkspace.serializer(), element), + ) + "session" -> CustomizationEnablement.Session( + input.json.decodeFromJsonElement(CustomizationEnablementSession.serializer(), element), + ) + else -> error("Unknown CustomizationEnablement kind") + } + } + + override fun serialize(encoder: Encoder, value: CustomizationEnablement) { + val output = encoder as? JsonEncoder + ?: error("CustomizationEnablement can only be serialized to JSON") + val element: JsonElement = when (value) { + is CustomizationEnablement.Global -> + output.json.encodeToJsonElement(CustomizationEnablementGlobal.serializer(), value.value) + is CustomizationEnablement.Workspace -> + output.json.encodeToJsonElement(CustomizationEnablementWorkspace.serializer(), value.value) + is CustomizationEnablement.Session -> + output.json.encodeToJsonElement(CustomizationEnablementSession.serializer(), value.value) + } + output.encodeJsonElement(element) + } +}`; +} + /** * ChangesetOperationTarget — TS discriminated union over `{ kind: "resource" }` * and `{ kind: "range" }`. The variant structs are inline-only in TS (not @@ -2120,6 +2195,7 @@ function checkExhaustiveness(project: Project): void { 'ForkChatSource', // generateFixedChatSourceBranchKotlin() 'SideChatSource', // generateFixedChatSourceBranchKotlin() 'ChangesetOperationTarget', // generateChangesetOperationTargetKotlin() + 'CustomizationEnablement', // generateCustomizationEnablementKotlin() ]); const missing = [...imported].filter(n => !coveredByLists.has(n) && !knownSpecial.has(n)); diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index 58a3bb9e..a5a6cb0b 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -657,7 +657,7 @@ const STATE_ENUMS = [ 'ToolCallRiskAssessmentStatus', 'ToolCallCancellationReason', 'ConfirmationOptionKind', 'ToolCallContributorKind', - 'ToolResultContentType', 'CustomizationType', 'CustomizationLoadStatus', 'TerminalClaimKind', + 'ToolResultContentType', 'CustomizationType', 'CustomizationEnablementKind', 'CustomizationLoadStatus', 'TerminalClaimKind', 'McpServerStatus', 'McpAuthRequiredReason', 'ChangesetStatus', 'ChangesetOperationStatus', 'ChangesetOperationScope', 'ResourceChangeType', ]; @@ -1151,6 +1151,10 @@ function generateStateFile(project: Project): string { } } + lines.push('// ─── Customization Enablement Union ───────────────────────────────────────\n'); + lines.push(generateCustomizationEnablementRust()); + lines.push(''); + lines.push(generateToolInput()); lines.push(''); @@ -1330,7 +1334,7 @@ pub struct ${scope}ToolCallConfirmedAction { function generateActionsFile(project: Project): string { const lines: string[] = [GENERATED_HEADER]; lines.push('#[allow(unused_imports)]'); - lines.push('use crate::state::{AgentInfo, AgentSelection, Annotation, AnnotationEntry, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ConfirmationOption, ContentRef, Customization, ErrorInfo, McpAuthRequirement, McpServerState, ModelSelection, ResponsePart, SessionActiveClient, SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, TextRange, ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, ToolCallConfirmationReason, ToolCallCancellationReason, ToolDefinition, ToolInput, ToolResultContent, UsageInfo, Message, PendingMessageKind, Turn, ChangesetStatus, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, Changeset, ChatSummary};'); + lines.push('use crate::state::{AgentInfo, AgentSelection, Annotation, AnnotationEntry, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ConfirmationOption, ContentRef, Customization, CustomizationEnablement, ErrorInfo, McpAuthRequirement, McpServerState, ModelSelection, ResponsePart, SessionActiveClient, SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, TextRange, ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, ToolCallConfirmationReason, ToolCallCancellationReason, ToolDefinition, ToolInput, ToolResultContent, UsageInfo, Message, PendingMessageKind, Turn, ChangesetStatus, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, Changeset, ChatSummary};'); lines.push(''); // ActionType enum @@ -1538,6 +1542,27 @@ function generateCommandsFile(project: Project): string { return lines.join('\n'); } +function generateCustomizationEnablementRust(): string { + return `/// A single explicit customization enablement decision. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind")] +pub enum CustomizationEnablement { + #[serde(rename = "global")] + Global { + enabled: bool, + }, + #[serde(rename = "workspace")] + Workspace { + uri: Uri, + enabled: bool, + }, + #[serde(rename = "session")] + Session { + enabled: bool, + }, +}`; +} + function generateSubscribeParamsImplRust(): string { return `impl SubscribeParams { /// Create subscribe params with default delivery behavior. @@ -1927,6 +1952,7 @@ function checkExhaustiveness(project: Project): void { 'AhpErrorCodeWithData', 'JsonRpcErrorCode', 'ChangesetOperationTarget', + 'CustomizationEnablement', ]); const missing = [...imported].filter(n => !coveredByLists.has(n) && !knownSpecial.has(n)); diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index 6b486893..1893f4e5 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -609,7 +609,7 @@ const STATE_ENUMS = [ 'ToolCallRiskAssessmentStatus', 'ToolCallCancellationReason', 'ConfirmationOptionKind', 'ToolCallContributorKind', - 'ToolResultContentType', 'CustomizationType', 'CustomizationLoadStatus', 'TerminalClaimKind', + 'ToolResultContentType', 'CustomizationType', 'CustomizationEnablementKind', 'CustomizationLoadStatus', 'TerminalClaimKind', 'McpServerStatus', 'McpAuthRequiredReason', 'ChangesetStatus', 'ChangesetOperationStatus', 'ChangesetOperationScope', 'ResourceChangeType', ]; @@ -1151,6 +1151,10 @@ function generateStateFile(project: Project): string { } } + lines.push('// MARK: - Customization Enablement Union\n'); + lines.push(generateCustomizationEnablementSwift()); + lines.push(''); + lines.push('// MARK: - Tool Input\n'); lines.push(generateToolInput()); lines.push(''); @@ -1559,6 +1563,70 @@ function generateCommandsFile(project: Project): string { return lines.join('\n'); } +function generateCustomizationEnablementSwift(): string { + return `/// A single explicit customization enablement decision. +public enum CustomizationEnablement: Codable, Sendable { + case global(CustomizationEnablementGlobal) + case workspace(CustomizationEnablementWorkspace) + case session(CustomizationEnablementSession) + + private enum DiscriminantKey: String, CodingKey { + case kind + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DiscriminantKey.self) + switch try container.decode(String.self, forKey: .kind) { + case "global": + self = .global(try CustomizationEnablementGlobal(from: decoder)) + case "workspace": + self = .workspace(try CustomizationEnablementWorkspace(from: decoder)) + case "session": + self = .session(try CustomizationEnablementSession(from: decoder)) + default: + throw DecodingError.dataCorruptedError(forKey: .kind, in: container, debugDescription: "Unknown CustomizationEnablement kind") + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .global(let value): try value.encode(to: encoder) + case .workspace(let value): try value.encode(to: encoder) + case .session(let value): try value.encode(to: encoder) + } + } +} + +public struct CustomizationEnablementGlobal: Codable, Sendable { + public var kind: String = "global" + public var enabled: Bool + + public init(enabled: Bool) { + self.enabled = enabled + } +} + +public struct CustomizationEnablementWorkspace: Codable, Sendable { + public var kind: String = "workspace" + public var uri: URI + public var enabled: Bool + + public init(uri: URI, enabled: Bool) { + self.uri = uri + self.enabled = enabled + } +} + +public struct CustomizationEnablementSession: Codable, Sendable { + public var kind: String = "session" + public var enabled: Bool + + public init(enabled: Bool) { + self.enabled = enabled + } +}`; +} + function generateChangesetOperationTargetSwift(): string { return `/// Identifies the file or range a \`ChangesetOperation\` should act on. public enum ChangesetOperationTarget: Codable, Sendable { @@ -2139,6 +2207,7 @@ function checkExhaustiveness(project: Project): void { 'ForkChatSource', // generateFixedChatSourceBranchSwift() 'SideChatSource', // generateFixedChatSourceBranchSwift() 'ChangesetOperationTarget', // TS discriminated union; consumers should add a Swift case-iterable enum + 'CustomizationEnablement', // generateCustomizationEnablementSwift() ]); const missing = [...imported].filter(n => !coveredByLists.has(n) && !knownSpecial.has(n)); diff --git a/types/channels-session/actions.ts b/types/channels-session/actions.ts index 5ba4ef05..714070c1 100644 --- a/types/channels-session/actions.ts +++ b/types/channels-session/actions.ts @@ -11,6 +11,7 @@ import type { SessionActiveClient, SessionInputRequest, Customization, + CustomizationEnablement, McpServerState, } from './state.js'; import type { URI } from '../common/state.js'; @@ -370,16 +371,19 @@ export interface SessionCustomizationsChangedAction { * only matters while its container is enabled. Is a no-op when no * customization has the given `id`. * + * The `enablement` array completely replaces all explicit decisions. A caller + * changing one scope must include every decision it intends to preserve. + * * @category Session Actions * @version 1 * @clientDispatchable */ export interface SessionCustomizationToggledAction { type: ActionType.SessionCustomizationToggled; - /** The id of the container or child to toggle. */ + /** The id of the container or child to update. */ id: string; - /** Whether to enable or disable the targeted customization. */ - enabled: boolean; + /** The complete set of explicit decisions, replacing any existing set. */ + enablement: CustomizationEnablement[]; } /** diff --git a/types/channels-session/reducer.ts b/types/channels-session/reducer.ts index c62517b2..ebedd0d0 100644 --- a/types/channels-session/reducer.ts +++ b/types/channels-session/reducer.ts @@ -8,6 +8,9 @@ import { ActionType } from '../common/actions.js'; import type { SessionState, SessionInputRequest, + ChildCustomization, + Customization, + CustomizationEnablement, McpServerCustomization, } from './state.js'; import { @@ -105,6 +108,23 @@ function updateMcpServerCustomization( return { ...state, customizations: updated }; } +/** + * Replaces a customization's explicit enablement decisions and recomputes its + * effective {@link CustomizationBase.enabled | `enabled`} value. An empty set + * drops the field entirely, so a customization at its default carries no + * provenance. + */ +function applyCustomizationEnablement(customization: T, enablement: readonly CustomizationEnablement[]): T { + const next = { ...customization }; + next.enabled = enablement[0]?.enabled ?? true; + if (enablement.length > 0) { + next.enablement = [...enablement]; + } else { + delete next.enablement; + } + return next; +} + // ─── Session Reducer ───────────────────────────────────────────────────────── /** @@ -308,7 +328,7 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: const topIdx = list.findIndex(c => c.id === action.id); if (topIdx >= 0) { const updated = list.slice(); - updated[topIdx] = { ...list[topIdx], enabled: action.enabled }; + updated[topIdx] = applyCustomizationEnablement(list[topIdx], action.enablement); return { ...state, customizations: updated }; } for (let i = 0; i < list.length; i++) { @@ -325,7 +345,7 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: continue; } const newChildren = children.slice(); - newChildren[childIdx] = { ...children[childIdx], enabled: action.enabled }; + newChildren[childIdx] = applyCustomizationEnablement(children[childIdx], action.enablement); const updated = list.slice(); updated[i] = { ...container, children: newChildren }; return { ...state, customizations: updated }; diff --git a/types/channels-session/state.ts b/types/channels-session/state.ts index e26fecfc..8b8b2103 100644 --- a/types/channels-session/state.ts +++ b/types/channels-session/state.ts @@ -645,6 +645,23 @@ export const enum CustomizationType { McpServer = 'mcpServer', } +/** + * Scope at which customization enablement is decided. + * + * @category Customization Types + */ +export const enum CustomizationEnablementKind { + Global = 'global', + Workspace = 'workspace', + Session = 'session', +} + +/** A single explicit enablement decision. */ +export type CustomizationEnablement = + | { kind: CustomizationEnablementKind.Global; enabled: boolean } + | { kind: CustomizationEnablementKind.Workspace; uri: URI; enabled: boolean } + | { kind: CustomizationEnablementKind.Session; enabled: boolean }; + /** * Customization types that appear as children of a * {@link PluginCustomization} or {@link DirectoryCustomization}. @@ -683,6 +700,20 @@ interface CustomizationBase { uri: URI; /** Human-readable name. */ name: string; + /** + * Explicit enablement decisions for this customization, one entry per scope + * that has one. This is a wire contract: producers MUST publish entries + * sorted by descending specificity (Session, Workspace, then Global). + * The agent host emits at most one Workspace entry, for the session's primary + * working directory. Consumers MAY treat + * `enablement[0]` as the decisive decision and + * `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + * absent or empty array means no explicit decision exists, so the + * customization is enabled by default. + * + * Only the agent host publishes this; clients treat it as read-only provenance. + */ + enablement?: CustomizationEnablement[]; /** Icons for UI display. */ icons?: Icon[]; /** @@ -1024,7 +1055,8 @@ export interface HookCustomization extends ChildCustomizationBase { export interface McpServerCustomization extends CustomizationBase { type: CustomizationType.McpServer; /** - * Whether this MCP server is currently enabled. + * Whether this MCP server is effectively enabled after resolving all scopes. + * {@link CustomizationBase.enablement | `enablement`} records its inputs. */ enabled: boolean; /** diff --git a/types/test-cases/reducers/060-session-customizationtoggled-toggles-by-id.json b/types/test-cases/reducers/060-session-customizationtoggled-toggles-by-id.json index dfdcb10f..3a9bfe20 100644 --- a/types/test-cases/reducers/060-session-customizationtoggled-toggles-by-id.json +++ b/types/test-cases/reducers/060-session-customizationtoggled-toggles-by-id.json @@ -29,7 +29,21 @@ { "type": "session/customizationToggled", "id": "plugin-a", - "enabled": false + "enablement": [ + { + "kind": "session", + "enabled": false + }, + { + "kind": "workspace", + "uri": "file:///workspace", + "enabled": true + }, + { + "kind": "global", + "enabled": true + } + ] } ], "expected": { @@ -43,7 +57,22 @@ "id": "plugin-a", "uri": "https://plugins.example/a", "name": "Plugin A", - "enabled": false + "enabled": false, + "enablement": [ + { + "kind": "session", + "enabled": false + }, + { + "kind": "workspace", + "uri": "file:///workspace", + "enabled": true + }, + { + "kind": "global", + "enabled": true + } + ] }, { "type": "plugin", diff --git a/types/test-cases/reducers/061-session-customizationtoggled-is-no-op-for-unknown-id.json b/types/test-cases/reducers/061-session-customizationtoggled-is-no-op-for-unknown-id.json index e46cb7d1..23eb0bf2 100644 --- a/types/test-cases/reducers/061-session-customizationtoggled-is-no-op-for-unknown-id.json +++ b/types/test-cases/reducers/061-session-customizationtoggled-is-no-op-for-unknown-id.json @@ -22,7 +22,12 @@ { "type": "session/customizationToggled", "id": "plugin-unknown", - "enabled": false + "enablement": [ + { + "kind": "session", + "enabled": false + } + ] } ], "expected": { diff --git a/types/test-cases/reducers/062-session-customizationtoggled-is-no-op-when-customizations-undefined.json b/types/test-cases/reducers/062-session-customizationtoggled-is-no-op-when-customizations-undefined.json index fa303b4f..98a48cf1 100644 --- a/types/test-cases/reducers/062-session-customizationtoggled-is-no-op-when-customizations-undefined.json +++ b/types/test-cases/reducers/062-session-customizationtoggled-is-no-op-when-customizations-undefined.json @@ -13,7 +13,12 @@ { "type": "session/customizationToggled", "id": "plugin-a", - "enabled": false + "enablement": [ + { + "kind": "session", + "enabled": false + } + ] } ], "expected": { diff --git a/types/test-cases/reducers/225-session-customizationtoggled-toggles-child-by-id.json b/types/test-cases/reducers/225-session-customizationtoggled-toggles-child-by-id.json index 839c8155..c095907e 100644 --- a/types/test-cases/reducers/225-session-customizationtoggled-toggles-child-by-id.json +++ b/types/test-cases/reducers/225-session-customizationtoggled-toggles-child-by-id.json @@ -47,7 +47,12 @@ { "type": "session/customizationToggled", "id": "skill-1", - "enabled": false + "enablement": [ + { + "kind": "session", + "enabled": false + } + ] } ], "expected": { @@ -79,6 +84,12 @@ "uri": "https://plugins.example/a#skills/lint", "name": "lint", "enabled": false, + "enablement": [ + { + "kind": "session", + "enabled": false + } + ], "disableUserInvocation": true }, { diff --git a/types/test-cases/reducers/226-session-customizationtoggled-is-no-op-for-unknown-child-id.json b/types/test-cases/reducers/226-session-customizationtoggled-is-no-op-for-unknown-child-id.json index 54924146..5dd151d4 100644 --- a/types/test-cases/reducers/226-session-customizationtoggled-is-no-op-for-unknown-child-id.json +++ b/types/test-cases/reducers/226-session-customizationtoggled-is-no-op-for-unknown-child-id.json @@ -30,7 +30,12 @@ { "type": "session/customizationToggled", "id": "does-not-exist", - "enabled": false + "enablement": [ + { + "kind": "session", + "enabled": false + } + ] } ], "expected": { diff --git a/types/test-cases/reducers/263-session-customizationtoggled-clears-enablement.json b/types/test-cases/reducers/263-session-customizationtoggled-clears-enablement.json new file mode 100644 index 00000000..6e63c9fd --- /dev/null +++ b/types/test-cases/reducers/263-session-customizationtoggled-clears-enablement.json @@ -0,0 +1,62 @@ +{ + "description": "session/customizationToggled clears enablement and restores the default", + "reducer": "session", + "initial": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "creating", + "customizations": [ + { + "type": "mcpServer", + "id": "server-a", + "uri": "file:///workspace/.vscode/mcp.json", + "name": "Server A", + "enabled": false, + "enablement": [ + { + "kind": "workspace", + "uri": "file:///workspace", + "enabled": false + }, + { + "kind": "global", + "enabled": true + } + ], + "state": { + "kind": "stopped" + } + } + ], + "activeClients": [], + "chats": [] + }, + "actions": [ + { + "type": "session/customizationToggled", + "id": "server-a", + "enablement": [] + } + ], + "expected": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "creating", + "customizations": [ + { + "type": "mcpServer", + "id": "server-a", + "uri": "file:///workspace/.vscode/mcp.json", + "name": "Server A", + "enabled": true, + "state": { + "kind": "stopped" + } + } + ], + "activeClients": [], + "chats": [] + } +}