From 0f700719ac16bffb4c7494fa5285172e8ce93ef0 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 4 Aug 2026 13:59:46 -0700 Subject: [PATCH] session: scoped customization enablement Customizations gain an optional `enablement` array of explicit decisions, one per scope that has one: CustomizationEnablement = | { kind: 'global'; enabled: boolean } | { kind: 'workspace'; uri: URI; enabled: boolean } | { kind: 'session'; enabled: boolean } The array is a wire contract. Producers MUST publish entries sorted by descending specificity (session, workspace, then global), and the agent host emits at most one workspace entry, for the session's primary working directory. Consumers MAY therefore treat `enablement[0]` as decisive, with `enablement?.[0]?.enabled ?? true` as the effective value. An absent or empty array means no explicit decision, so the customization is enabled by default. Only the host publishes this; clients treat it as read-only provenance. The field lives on the customization base rather than on MCP servers alone, so it applies to every customization type. `session/customizationToggled` carries `enablement` in place of `enabled` and replaces the complete decision set, so a caller changing one scope must include every decision it intends to preserve. An empty array clears all decisions and restores the default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clients/go/ahp/reducers.go | 41 +++- clients/go/ahptypes/actions.generated.go | 9 +- clients/go/ahptypes/state.generated.go | 188 +++++++++++++++- .../microsoft/agenthostprotocol/Reducers.kt | 41 ++-- .../generated/Actions.generated.kt | 6 +- .../generated/State.generated.kt | 212 +++++++++++++++++- clients/rust/crates/ahp-types/src/actions.rs | 20 +- clients/rust/crates/ahp-types/src/state.rs | 145 +++++++++++- clients/rust/crates/ahp/src/reducers.rs | 73 ++++-- .../Generated/Actions.generated.swift | 10 +- .../Generated/State.generated.swift | 209 ++++++++++++++++- .../AgentHostProtocol/NativeReducer.swift | 32 ++- .../Sources/AgentHostProtocol/Reducers.swift | 2 +- ...60804-scoped-customization-enablement.json | 4 + docs/guide/actions.md | 4 +- docs/guide/customizations.md | 46 +++- schema/actions.schema.json | 155 ++++++++++++- schema/commands.schema.json | 155 ++++++++++++- schema/errors.schema.json | 155 ++++++++++++- schema/notifications.schema.json | 140 +++++++++++- schema/state.schema.json | 140 +++++++++++- scripts/generate-go.ts | 76 ++++++- scripts/generate-kotlin.ts | 78 ++++++- scripts/generate-rust.ts | 30 ++- scripts/generate-swift.ts | 71 +++++- types/channels-session/actions.ts | 10 +- types/channels-session/reducer.ts | 24 +- types/channels-session/state.ts | 34 ++- ...on-customizationtoggled-toggles-by-id.json | 33 ++- ...zationtoggled-is-no-op-for-unknown-id.json | 7 +- ...s-no-op-when-customizations-undefined.json | 7 +- ...tomizationtoggled-toggles-child-by-id.json | 13 +- ...toggled-is-no-op-for-unknown-child-id.json | 7 +- ...ustomizationtoggled-clears-enablement.json | 62 +++++ 34 files changed, 2127 insertions(+), 112 deletions(-) create mode 100644 docs/.changes/20260804-scoped-customization-enablement.json create mode 100644 types/test-cases/reducers/263-session-customizationtoggled-clears-enablement.json 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": [] + } +}