diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cde81419..f2d98284c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,9 +23,9 @@ changes accumulate. Track in-flight protocol changes via PRs touching `NOTIFICATION_INTRODUCED_IN` maps in [`types/version/registry.ts`](types/version/registry.ts). -## [0.6.1] — Unreleased +## [0.7.0] — Unreleased -Spec version: `0.6.1` +Spec version: `0.7.0` ## [0.6.0] — 2026-07-20 diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go index 24768a1c8..43f659a4b 100644 --- a/clients/go/ahp/reducers.go +++ b/clients/go/ahp/reducers.go @@ -514,6 +514,22 @@ func ApplyActionToChat(state *ahptypes.ChatState, action ahptypes.StateAction) R case *ahptypes.ChatActivityChangedAction: state.Activity = a.Activity return ReduceOutcomeApplied + case *ahptypes.ChatWorkingDirectorySetAction: + for _, d := range state.WorkingDirectories { + if d == a.Directory { + return ReduceOutcomeNoOp + } + } + state.WorkingDirectories = append(state.WorkingDirectories, a.Directory) + return ReduceOutcomeApplied + case *ahptypes.ChatWorkingDirectoryRemovedAction: + for i := range state.WorkingDirectories { + if state.WorkingDirectories[i] == a.Directory { + state.WorkingDirectories = append(state.WorkingDirectories[:i], state.WorkingDirectories[i+1:]...) + return ReduceOutcomeApplied + } + } + return ReduceOutcomeNoOp case *ahptypes.ChatToolCallStartAction: if state.ActiveTurn == nil || state.ActiveTurn.Id != a.TurnId { return ReduceOutcomeNoOp @@ -751,8 +767,8 @@ func mergeChatSummaryPartial(summary *ahptypes.ChatSummary, changes ahptypes.Par if changes.Origin != nil { summary.Origin = changes.Origin } - if changes.WorkingDirectory != nil { - summary.WorkingDirectory = changes.WorkingDirectory + if changes.WorkingDirectories != nil { + summary.WorkingDirectories = changes.WorkingDirectories } } @@ -858,6 +874,22 @@ func ApplyActionToSession(state *ahptypes.SessionState, action ahptypes.StateAct } } return ReduceOutcomeNoOp + case *ahptypes.SessionWorkingDirectorySetAction: + for _, d := range state.WorkingDirectories { + if d == a.Directory { + return ReduceOutcomeNoOp + } + } + state.WorkingDirectories = append(state.WorkingDirectories, a.Directory) + return ReduceOutcomeApplied + case *ahptypes.SessionWorkingDirectoryRemovedAction: + for i := range state.WorkingDirectories { + if state.WorkingDirectories[i] == a.Directory { + state.WorkingDirectories = append(state.WorkingDirectories[:i], state.WorkingDirectories[i+1:]...) + return ReduceOutcomeApplied + } + } + return ReduceOutcomeNoOp case *ahptypes.SessionInputNeededSetAction: id, ok := sessionInputRequestID(a.Request) if !ok { diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index 145b40b34..946b45746 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -43,12 +43,16 @@ const ( ActionTypeChatTurnCancelled ActionType = "chat/turnCancelled" ActionTypeChatError ActionType = "chat/error" ActionTypeChatActivityChanged ActionType = "chat/activityChanged" + ActionTypeChatWorkingDirectorySet ActionType = "chat/workingDirectorySet" + ActionTypeChatWorkingDirectoryRemoved ActionType = "chat/workingDirectoryRemoved" ActionTypeSessionTitleChanged ActionType = "session/titleChanged" ActionTypeChatUsage ActionType = "chat/usage" ActionTypeChatReasoning ActionType = "chat/reasoning" ActionTypeSessionServerToolsChanged ActionType = "session/serverToolsChanged" ActionTypeSessionActiveClientSet ActionType = "session/activeClientSet" ActionTypeSessionActiveClientRemoved ActionType = "session/activeClientRemoved" + ActionTypeSessionWorkingDirectorySet ActionType = "session/workingDirectorySet" + ActionTypeSessionWorkingDirectoryRemoved ActionType = "session/workingDirectoryRemoved" ActionTypeSessionInputNeededSet ActionType = "session/inputNeededSet" ActionTypeSessionInputNeededRemoved ActionType = "session/inputNeededRemoved" ActionTypeChatPendingMessageSet ActionType = "chat/pendingMessageSet" @@ -875,6 +879,61 @@ type SessionActiveClientRemovedAction struct { ClientId string `json:"clientId"` } +// A working directory was added to the session's +// {@link SessionState.workingDirectories} set. +// +// Membership semantics keyed by the directory URI: the reducer appends +// `directory` when the set does not already contain it (creating the set if +// absent) and is a no-op when it is already present. Only valid when the agent +// advertises {@link AgentCapabilities.multipleWorkingDirectories}. +type SessionWorkingDirectorySetAction struct { + Type ActionType `json:"type"` + // The working directory to grant the session's agent tool access to. + Directory URI `json:"directory"` +} + +// A working directory was removed from the session's +// {@link SessionState.workingDirectories} set. +// +// Removes `directory` from the set; a no-op when it is not present. There is no +// atomic backend "remove one" primitive — a host reconfigures its agent to the +// reduced set — so this action is safe to model as idempotent. A host MAY +// decline to apply the removal (e.g. a directory still designated as some +// chat's {@link ChatState.primaryWorkingDirectory | primary}); it then leaves +// the set unchanged. +type SessionWorkingDirectoryRemovedAction struct { + Type ActionType `json:"type"` + // The working directory to revoke the session's agent tool access to. + Directory URI `json:"directory"` +} + +// A working directory was added to this chat's +// {@link ChatState.workingDirectories} subset. +// +// Membership semantics keyed by the directory URI: the reducer appends +// `directory` when the chat's subset does not already contain it (creating the +// subset if absent) and is a no-op when it is already present. `directory` MUST +// be one of the owning session's {@link SessionState.workingDirectories}; a host +// MUST reject a directory that is not. Only valid when the agent advertises +// {@link AgentCapabilities.multipleWorkingDirectories}. +type ChatWorkingDirectorySetAction struct { + Type ActionType `json:"type"` + // The working directory to add to this chat's subset. + Directory URI `json:"directory"` +} + +// A working directory was removed from this chat's +// {@link ChatState.workingDirectories} subset. +// +// Removes `directory` from the chat's subset; a no-op when it is not present. +// Idempotent, mirroring `session/workingDirectoryRemoved`. Only affects the +// chat's subset — the directory remains in the session's set. +type ChatWorkingDirectoryRemovedAction struct { + Type ActionType `json:"type"` + // The working directory to remove from this chat's subset. + Directory URI `json:"directory"` +} + // A session-level input request was added or updated. // // Upsert semantics keyed by {@link SessionInputRequest.id | `request.id`}: the @@ -1470,6 +1529,10 @@ func (*SessionChangesetsChangedAction) isStateAction() {} func (*SessionServerToolsChangedAction) isStateAction() {} func (*SessionActiveClientSetAction) isStateAction() {} func (*SessionActiveClientRemovedAction) isStateAction() {} +func (*SessionWorkingDirectorySetAction) isStateAction() {} +func (*SessionWorkingDirectoryRemovedAction) isStateAction() {} +func (*ChatWorkingDirectorySetAction) isStateAction() {} +func (*ChatWorkingDirectoryRemovedAction) isStateAction() {} func (*SessionInputNeededSetAction) isStateAction() {} func (*SessionInputNeededRemovedAction) isStateAction() {} func (*SessionCustomizationsChangedAction) isStateAction() {} @@ -1786,6 +1849,30 @@ func (u *StateAction) UnmarshalJSON(data []byte) error { return err } u.Value = &value + case "session/workingDirectorySet": + var value SessionWorkingDirectorySetAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "session/workingDirectoryRemoved": + var value SessionWorkingDirectoryRemovedAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "chat/workingDirectorySet": + var value ChatWorkingDirectorySetAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value + case "chat/workingDirectoryRemoved": + var value ChatWorkingDirectoryRemovedAction + if err := json.Unmarshal(data, &value); err != nil { + return err + } + u.Value = &value case "session/inputNeededSet": var value SessionInputNeededSetAction if err := json.Unmarshal(data, &value); err != nil { diff --git a/clients/go/ahptypes/commands.generated.go b/clients/go/ahptypes/commands.generated.go index f731e8520..533f9d885 100644 --- a/clients/go/ahptypes/commands.generated.go +++ b/clients/go/ahptypes/commands.generated.go @@ -305,8 +305,41 @@ type CreateSessionParams struct { Channel URI `json:"channel"` // Agent provider ID Provider *string `json:"provider,omitempty"` - // Working directory for the session - WorkingDirectory *URI `json:"workingDirectory,omitempty"` + // The working directories the session's agent is granted tool access to. + // A session may span multiple directories; they are equal peers except when + // the agent advertises + // {@link MultipleWorkingDirectoriesCapability.requiresPrimary}, in which case + // one of them should be designated the primary via + // {@link primaryWorkingDirectory}. + // + // A client MUST NOT supply more than one entry unless the agent advertises + // {@link AgentCapabilities.multipleWorkingDirectories}; a server without that + // capability treats only the first entry as the session's working directory + // and ignores the rest. Dispatch `session/workingDirectorySet` / + // `session/workingDirectoryRemoved` to change the set after the session has + // started. + // + // Ignored for forked sessions — a fork inherits its working directories + // from the source session identified by `fork`. + WorkingDirectories []URI `json:"workingDirectories,omitempty"` + // The primary working directory for the session's **default chat**. + // + // A session has no primary of its own — primary is a per-chat notion (see + // {@link ChatState.primaryWorkingDirectory}). But `createSession` implicitly + // creates the session's default chat, and there is no separate `createChat` + // call to carry that chat's create-time fields. This field is therefore the + // only place a client can designate the **default chat's** primary at birth; + // it is copied into that chat's read-only `primaryWorkingDirectory`. For any + // non-default chat, pass {@link CreateChatParams.primaryWorkingDirectory} + // instead. + // + // When set, it MUST be one of {@link workingDirectories}. A client SHOULD + // supply this when the agent advertises + // {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY + // reject creation that omits it, or fall back to the first entry of + // `workingDirectories`. Ignored for forked sessions (a fork inherits the + // source session's chats and their primaries). + PrimaryWorkingDirectory *URI `json:"primaryWorkingDirectory,omitempty"` // Fork from an existing session. The new session is populated with content // from the source session up to and including the specified turn's response. Fork *SessionForkSource `json:"fork,omitempty"` @@ -359,6 +392,25 @@ type CreateChatParams struct { InitialMessage *Message `json:"initialMessage,omitempty"` // Optional source chat and turn to fork from. Source *ChatForkSource `json:"source,omitempty"` + // Initial working-directory subset for this chat. Every entry MUST be + // present in the owning session's `workingDirectories`; the server MUST + // reject any entry that is not. When absent, the chat inherits the full + // session set. Forked chats (`source`) inherit the source chat's + // `workingDirectories`; this field is ignored for forked chats. + // + // A client MUST NOT supply this field unless the agent advertises + // {@link AgentCapabilities.multipleWorkingDirectories}. + WorkingDirectories []URI `json:"workingDirectories,omitempty"` + // The chat's primary working directory — the distinguished root this chat is + // centered on. When set, it MUST be one of the chat's effective working + // directories ({@link workingDirectories}, or the session's set when that is + // omitted). A client SHOULD supply this when the agent advertises + // {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY + // reject creation that omits it, or fall back to the first of the chat's + // directories. Fixed at creation and reported (read-only) on + // {@link ChatState.primaryWorkingDirectory}. Ignored for forked chats (a fork + // inherits the source chat's primary). + PrimaryWorkingDirectory *URI `json:"primaryWorkingDirectory,omitempty"` } // Disposes a chat and cleans up server-side resources. diff --git a/clients/go/ahptypes/common.go b/clients/go/ahptypes/common.go index 83ed98b32..233051bbf 100644 --- a/clients/go/ahptypes/common.go +++ b/clients/go/ahptypes/common.go @@ -83,8 +83,10 @@ type PartialChatSummary struct { ModifiedAt *string `json:"modifiedAt,omitempty"` // How this chat came into existence Origin *ChatOrigin `json:"origin,omitempty"` - // Optional per-chat working directory. - WorkingDirectory *URI `json:"workingDirectory,omitempty"` + // The subset of the session's working directories this chat uses. + WorkingDirectories []URI `json:"workingDirectories,omitempty"` + // The chat's primary working directory. + PrimaryWorkingDirectory *URI `json:"primaryWorkingDirectory,omitempty"` } // ─── StringOrMarkdown ──────────────────────────────────────────────────── diff --git a/clients/go/ahptypes/notifications.generated.go b/clients/go/ahptypes/notifications.generated.go index 6c214d5fc..ed5539968 100644 --- a/clients/go/ahptypes/notifications.generated.go +++ b/clients/go/ahptypes/notifications.generated.go @@ -217,11 +217,15 @@ type PartialSessionSummary struct { Activity *string `json:"activity,omitempty"` // Server-owned project for this session Project *ProjectInfo `json:"project,omitempty"` - // The default working directory URI for this session. Individual chats - // MAY override via {@link ChatSummary.workingDirectory | their own - // `workingDirectory`}; this field acts as the fallback for any chat that - // does not. - WorkingDirectory *URI `json:"workingDirectory,omitempty"` + // The working directories the session's agent has tool access to, as + // maintained by the `session/workingDirectorySet` / + // `session/workingDirectoryRemoved` actions. Directories are **equal peers** — + // the session has no primary. Individual chats MAY restrict to a subset via + // {@link ChatSummary.workingDirectories | their own `workingDirectories`} and + // designate one of their own directories as primary (see + // {@link ChatState.primaryWorkingDirectory}); a chat that sets no subset + // operates against this full set. + WorkingDirectories []URI `json:"workingDirectories,omitempty"` // Lightweight summary of this session's inline annotations channel // (`ahp-session://annotations`). Surfaced so badge UI can render // annotation / entry counts without subscribing. Absent when the session diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index 8520b6643..3f5ba0412 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -584,6 +584,15 @@ type AgentCapabilities struct { // session starts with. An empty object `{}` advertises multi-chat without // forking; set {@link MultipleChatsCapability.fork} to also allow forking. MultipleChats *MultipleChatsCapability `json:"multipleChats,omitempty"` + // The session's agent can be granted tool access to more than one working + // directory. The directories are treated as equal peers except where the + // agent advertises {@link MultipleWorkingDirectoriesCapability.requiresPrimary} + // (some backends need one directory designated as a primary root). + // + // When absent, clients MUST NOT mutate a session's or chat's working-directory + // set and MUST NOT set more than one entry in + // {@link CreateSessionParams.workingDirectories}. + MultipleWorkingDirectories *MultipleWorkingDirectoriesCapability `json:"multipleWorkingDirectories,omitempty"` } // Options for the {@link AgentCapabilities.multipleChats} capability. @@ -594,6 +603,23 @@ type MultipleChatsCapability struct { Fork *bool `json:"fork,omitempty"` } +// Options for the {@link AgentCapabilities.multipleWorkingDirectories} capability. +type MultipleWorkingDirectoriesCapability struct { + // The agent requires each chat to designate one of its working directories as + // the **primary** — a distinguished root the chat is centered on (e.g. the + // agent's process root for that chat, the default location for relative + // paths). Primary is a **per-chat** notion, fixed at chat creation. When + // `true`, a client SHOULD supply {@link CreateChatParams.primaryWorkingDirectory} + // (and {@link CreateSessionParams.primaryWorkingDirectory}, which seeds the + // session's default chat); a host MAY reject creation that omits it, or fall + // back to the first entry of the chat's working directories. The chosen + // primary is reported (read-only) on {@link ChatState.primaryWorkingDirectory}. + // + // When absent or `false`, the agent has no primary — all directories are + // equal peers and clients need not designate one. + RequiresPrimary *bool `json:"requiresPrimary,omitempty"` +} + type SessionModelInfo struct { // Model identifier Id string `json:"id"` @@ -714,11 +740,15 @@ type SessionState struct { Activity *string `json:"activity,omitempty"` // Server-owned project for this session Project *ProjectInfo `json:"project,omitempty"` - // The default working directory URI for this session. Individual chats - // MAY override via {@link ChatSummary.workingDirectory | their own - // `workingDirectory`}; this field acts as the fallback for any chat that - // does not. - WorkingDirectory *URI `json:"workingDirectory,omitempty"` + // The working directories the session's agent has tool access to, as + // maintained by the `session/workingDirectorySet` / + // `session/workingDirectoryRemoved` actions. Directories are **equal peers** — + // the session has no primary. Individual chats MAY restrict to a subset via + // {@link ChatSummary.workingDirectories | their own `workingDirectories`} and + // designate one of their own directories as primary (see + // {@link ChatState.primaryWorkingDirectory}); a chat that sets no subset + // operates against this full set. + WorkingDirectories []URI `json:"workingDirectories,omitempty"` // Lightweight summary of this session's inline annotations channel // (`ahp-session://annotations`). Surfaced so badge UI can render // annotation / entry counts without subscribing. Absent when the session @@ -794,7 +824,7 @@ type SessionState struct { // // Clients MAY look for well-known keys here to provide enhanced UI. // For example, a `git` key may provide extra git metadata about the session's - // workingDirectory. + // working directories. Meta map[string]json.RawMessage `json:"_meta,omitempty"` } @@ -954,9 +984,9 @@ type SessionToolAuthenticationRequest struct { // chat currently driving the promoted status bits when a non-default chat // wins (e.g. the chat that raised `InputNeeded`). // - `modifiedAt`: the max of all chats' `modifiedAt`. -// - `workingDirectory`: the session-level **default**. Individual chats MAY -// override via {@link ChatSummary.workingDirectory}; aggregating these up -// is meaningless and SHOULD NOT be attempted. +// - `workingDirectories`: the session-level set. Individual chats MAY restrict +// to a subset via {@link ChatSummary.workingDirectories}; aggregating these +// up is meaningless and SHOULD NOT be attempted. // - `changes`: optional roll-up across all chats. Producers MAY sum the // per-chat changeset stats or report the most expensive chat's stats — // whichever is cheaper for the host to compute. @@ -975,11 +1005,15 @@ type SessionSummary struct { Activity *string `json:"activity,omitempty"` // Server-owned project for this session Project *ProjectInfo `json:"project,omitempty"` - // The default working directory URI for this session. Individual chats - // MAY override via {@link ChatSummary.workingDirectory | their own - // `workingDirectory`}; this field acts as the fallback for any chat that - // does not. - WorkingDirectory *URI `json:"workingDirectory,omitempty"` + // The working directories the session's agent has tool access to, as + // maintained by the `session/workingDirectorySet` / + // `session/workingDirectoryRemoved` actions. Directories are **equal peers** — + // the session has no primary. Individual chats MAY restrict to a subset via + // {@link ChatSummary.workingDirectories | their own `workingDirectories`} and + // designate one of their own directories as primary (see + // {@link ChatState.primaryWorkingDirectory}); a chat that sets no subset + // operates against this full set. + WorkingDirectories []URI `json:"workingDirectories,omitempty"` // Lightweight summary of this session's inline annotations channel // (`ahp-session://annotations`). Surfaced so badge UI can render // annotation / entry counts without subscribing. Absent when the session @@ -1046,14 +1080,31 @@ type ChatState struct { // Absence defaults to {@link ChatInteractivity.Full} for backward // compatibility. Interactivity *ChatInteractivity `json:"interactivity,omitempty"` - // Optional per-chat working directory. + // The subset of the session's + // {@link SessionState.workingDirectories | `workingDirectories`} that this + // chat's agent has tool access to. Every entry MUST be present in the owning + // session's `workingDirectories`; servers MUST reject a + // `chat/workingDirectorySet` action that violates this constraint. // - // If absent, the chat inherits - // {@link SessionState.workingDirectory | the session's working directory}. - // Hosts MAY override this for individual chats — for example, to give a - // subordinate chat its own git worktree so multiple chats in a session can - // make independent edits that the orchestrator later merges back. - WorkingDirectory *URI `json:"workingDirectory,omitempty"` + // When absent, the chat inherits the full session set. When present but empty + // (not recommended), the chat has no working-directory tool access at all. + // + // Dispatch `chat/workingDirectorySet` / `chat/workingDirectoryRemoved` to + // update the subset on a running chat. + WorkingDirectories []URI `json:"workingDirectories,omitempty"` + // The chat's primary working directory — the distinguished root this chat is + // centered on (e.g. the agent's process root for this chat, the default + // location for relative paths). MUST be one of this chat's effective working + // directories ({@link workingDirectories}, or the session's set when that is + // absent). Present when the agent advertises + // {@link MultipleWorkingDirectoriesCapability.requiresPrimary}. + // + // **Read-only and fixed at creation.** It is set from + // {@link CreateChatParams.primaryWorkingDirectory} (or, for the session's + // default chat, {@link CreateSessionParams.primaryWorkingDirectory}) and does + // not change over the chat's lifetime — there is no action to mutate it, and + // it does not participate in `session/chatUpdated`. + PrimaryWorkingDirectory *URI `json:"primaryWorkingDirectory,omitempty"` // Completed turns Turns []Turn `json:"turns"` // Cursor for loading older completed turns into this chat state. @@ -1107,12 +1158,12 @@ type ChatSummary struct { // Absence defaults to {@link ChatInteractivity.Full} for backward // compatibility. Interactivity *ChatInteractivity `json:"interactivity,omitempty"` - // Optional per-chat working directory. - // - // If absent, the chat inherits - // {@link SessionSummary.workingDirectory | the session's working directory}. - // See {@link ChatState.workingDirectory} for usage notes. - WorkingDirectory *URI `json:"workingDirectory,omitempty"` + // The subset of the session's working directories this chat uses. + // See {@link ChatState.workingDirectories} for the full semantics. + WorkingDirectories []URI `json:"workingDirectories,omitempty"` + // The chat's primary working directory. + // See {@link ChatState.primaryWorkingDirectory} for the full semantics. + PrimaryWorkingDirectory *URI `json:"primaryWorkingDirectory,omitempty"` } // A message queued for future delivery to the agent. diff --git a/clients/go/ahptypes/version.generated.go b/clients/go/ahptypes/version.generated.go index 26d70813d..058869903 100644 --- a/clients/go/ahptypes/version.generated.go +++ b/clients/go/ahptypes/version.generated.go @@ -6,13 +6,13 @@ package ahptypes // ProtocolVersion is the current protocol version (SemVer // MAJOR.MINOR.PATCH) that this generated source speaks. -const ProtocolVersion = "0.6.1" +const ProtocolVersion = "0.7.0" // supportedProtocolVersions backs [SupportedProtocolVersions] — held // in an unexported slice so callers cannot accidentally mutate the // shared backing array. var supportedProtocolVersions = []string{ - "0.6.1", + "0.7.0", "0.6.0", "0.5.2", "0.5.1", diff --git a/clients/go/release-metadata.json b/clients/go/release-metadata.json index cb60446f6..f26219c29 100644 --- a/clients/go/release-metadata.json +++ b/clients/go/release-metadata.json @@ -2,7 +2,7 @@ "client": "go", "packageVersion": "0.6.0", "supportedProtocolVersions": [ - "0.6.1", + "0.7.0", "0.6.0", "0.5.2", "0.5.1" diff --git a/clients/kotlin/release-metadata.json b/clients/kotlin/release-metadata.json index 251a45a6b..c6669d28d 100644 --- a/clients/kotlin/release-metadata.json +++ b/clients/kotlin/release-metadata.json @@ -2,7 +2,7 @@ "client": "kotlin", "packageVersion": "0.6.0", "supportedProtocolVersions": [ - "0.6.1", + "0.7.0", "0.6.0", "0.5.2", "0.5.1" 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 7912470cd..e681f91fb 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -545,7 +545,7 @@ public fun sessionReducer(state: SessionState, action: StateAction): SessionStat activity = c.activity ?: prior.activity, modifiedAt = c.modifiedAt ?: prior.modifiedAt, origin = c.origin ?: prior.origin, - workingDirectory = c.workingDirectory ?: prior.workingDirectory, + workingDirectories = c.workingDirectories ?: prior.workingDirectories, ) val updated = state.chats.toMutableList() updated[idx] = updatedSummary @@ -605,6 +605,27 @@ public fun sessionReducer(state: SessionState, action: StateAction): SessionStat } } + is StateActionSessionWorkingDirectorySet -> { + val list = state.workingDirectories ?: emptyList() + if (list.contains(action.value.directory)) { + state + } else { + state.copy(workingDirectories = list + action.value.directory) + } + } + + is StateActionSessionWorkingDirectoryRemoved -> { + val list = state.workingDirectories + val idx = list?.indexOf(action.value.directory) ?: -1 + if (list == null || idx < 0) { + state + } else { + val updated = list.toMutableList() + updated.removeAt(idx) + state.copy(workingDirectories = updated) + } + } + is StateActionSessionInputNeededSet -> { val request = action.value.request val id = sessionInputRequestId(request) @@ -843,6 +864,27 @@ public fun chatReducer(state: ChatState, action: StateAction): ChatState = when is StateActionChatActivityChanged -> state.copy(activity = action.value.activity) + is StateActionChatWorkingDirectorySet -> { + val list = state.workingDirectories ?: emptyList() + if (list.contains(action.value.directory)) { + state + } else { + state.copy(workingDirectories = list + action.value.directory) + } + } + + is StateActionChatWorkingDirectoryRemoved -> { + val list = state.workingDirectories + val idx = list?.indexOf(action.value.directory) ?: -1 + if (list == null || idx < 0) { + state + } else { + val updated = list.toMutableList() + updated.removeAt(idx) + state.copy(workingDirectories = updated) + } + } + // ── Tool Call State Machine ─────────────────────────────────────────── is StateActionChatToolCallStart -> { 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 6aac9f8f5..1dd8e4709 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 @@ -74,6 +74,10 @@ enum class ActionType { CHAT_ERROR, @SerialName("chat/activityChanged") CHAT_ACTIVITY_CHANGED, + @SerialName("chat/workingDirectorySet") + CHAT_WORKING_DIRECTORY_SET, + @SerialName("chat/workingDirectoryRemoved") + CHAT_WORKING_DIRECTORY_REMOVED, @SerialName("session/titleChanged") SESSION_TITLE_CHANGED, @SerialName("chat/usage") @@ -86,6 +90,10 @@ enum class ActionType { SESSION_ACTIVE_CLIENT_SET, @SerialName("session/activeClientRemoved") SESSION_ACTIVE_CLIENT_REMOVED, + @SerialName("session/workingDirectorySet") + SESSION_WORKING_DIRECTORY_SET, + @SerialName("session/workingDirectoryRemoved") + SESSION_WORKING_DIRECTORY_REMOVED, @SerialName("session/inputNeededSet") SESSION_INPUT_NEEDED_SET, @SerialName("session/inputNeededRemoved") @@ -882,6 +890,42 @@ data class SessionActiveClientRemovedAction( val clientId: String ) +@Serializable +data class SessionWorkingDirectorySetAction( + val type: ActionType, + /** + * The working directory to grant the session's agent tool access to. + */ + val directory: String +) + +@Serializable +data class SessionWorkingDirectoryRemovedAction( + val type: ActionType, + /** + * The working directory to revoke the session's agent tool access to. + */ + val directory: String +) + +@Serializable +data class ChatWorkingDirectorySetAction( + val type: ActionType, + /** + * The working directory to add to this chat's subset. + */ + val directory: String +) + +@Serializable +data class ChatWorkingDirectoryRemovedAction( + val type: ActionType, + /** + * The working directory to remove from this chat's subset. + */ + val directory: String +) + @Serializable data class SessionInputNeededSetAction( val type: ActionType, @@ -1463,13 +1507,15 @@ data class PartialChatSummary( */ val interactivity: ChatInteractivity? = null, /** - * Optional per-chat working directory. - * - * If absent, the chat inherits - * {@link SessionSummary.workingDirectory | the session's working directory}. - * See {@link ChatState.workingDirectory} for usage notes. + * The subset of the session's working directories this chat uses. + * See {@link ChatState.workingDirectories} for the full semantics. + */ + val workingDirectories: List? = null, + /** + * The chat's primary working directory. + * See {@link ChatState.primaryWorkingDirectory} for the full semantics. */ - val workingDirectory: String? = null + val primaryWorkingDirectory: String? = null ) // ─── StateAction Union ────────────────────────────────────────────────────── @@ -1520,6 +1566,10 @@ sealed interface StateAction @JvmInline value class StateActionSessionServerToolsChanged(val value: SessionServerToolsChangedAction) : StateAction @JvmInline value class StateActionSessionActiveClientSet(val value: SessionActiveClientSetAction) : StateAction @JvmInline value class StateActionSessionActiveClientRemoved(val value: SessionActiveClientRemovedAction) : StateAction +@JvmInline value class StateActionSessionWorkingDirectorySet(val value: SessionWorkingDirectorySetAction) : StateAction +@JvmInline value class StateActionSessionWorkingDirectoryRemoved(val value: SessionWorkingDirectoryRemovedAction) : StateAction +@JvmInline value class StateActionChatWorkingDirectorySet(val value: ChatWorkingDirectorySetAction) : StateAction +@JvmInline value class StateActionChatWorkingDirectoryRemoved(val value: ChatWorkingDirectoryRemovedAction) : StateAction @JvmInline value class StateActionSessionInputNeededSet(val value: SessionInputNeededSetAction) : StateAction @JvmInline value class StateActionSessionInputNeededRemoved(val value: SessionInputNeededRemovedAction) : StateAction @JvmInline value class StateActionChatPendingMessageSet(val value: ChatPendingMessageSetAction) : StateAction @@ -1616,6 +1666,10 @@ internal object StateActionSerializer : KSerializer { "session/serverToolsChanged" -> StateActionSessionServerToolsChanged(input.json.decodeFromJsonElement(SessionServerToolsChangedAction.serializer(), element)) "session/activeClientSet" -> StateActionSessionActiveClientSet(input.json.decodeFromJsonElement(SessionActiveClientSetAction.serializer(), element)) "session/activeClientRemoved" -> StateActionSessionActiveClientRemoved(input.json.decodeFromJsonElement(SessionActiveClientRemovedAction.serializer(), element)) + "session/workingDirectorySet" -> StateActionSessionWorkingDirectorySet(input.json.decodeFromJsonElement(SessionWorkingDirectorySetAction.serializer(), element)) + "session/workingDirectoryRemoved" -> StateActionSessionWorkingDirectoryRemoved(input.json.decodeFromJsonElement(SessionWorkingDirectoryRemovedAction.serializer(), element)) + "chat/workingDirectorySet" -> StateActionChatWorkingDirectorySet(input.json.decodeFromJsonElement(ChatWorkingDirectorySetAction.serializer(), element)) + "chat/workingDirectoryRemoved" -> StateActionChatWorkingDirectoryRemoved(input.json.decodeFromJsonElement(ChatWorkingDirectoryRemovedAction.serializer(), element)) "session/inputNeededSet" -> StateActionSessionInputNeededSet(input.json.decodeFromJsonElement(SessionInputNeededSetAction.serializer(), element)) "session/inputNeededRemoved" -> StateActionSessionInputNeededRemoved(input.json.decodeFromJsonElement(SessionInputNeededRemovedAction.serializer(), element)) "chat/pendingMessageSet" -> StateActionChatPendingMessageSet(input.json.decodeFromJsonElement(ChatPendingMessageSetAction.serializer(), element)) @@ -1705,6 +1759,10 @@ internal object StateActionSerializer : KSerializer { is StateActionSessionServerToolsChanged -> output.json.encodeToJsonElement(SessionServerToolsChangedAction.serializer(), value.value) is StateActionSessionActiveClientSet -> output.json.encodeToJsonElement(SessionActiveClientSetAction.serializer(), value.value) is StateActionSessionActiveClientRemoved -> output.json.encodeToJsonElement(SessionActiveClientRemovedAction.serializer(), value.value) + is StateActionSessionWorkingDirectorySet -> output.json.encodeToJsonElement(SessionWorkingDirectorySetAction.serializer(), value.value) + is StateActionSessionWorkingDirectoryRemoved -> output.json.encodeToJsonElement(SessionWorkingDirectoryRemovedAction.serializer(), value.value) + is StateActionChatWorkingDirectorySet -> output.json.encodeToJsonElement(ChatWorkingDirectorySetAction.serializer(), value.value) + is StateActionChatWorkingDirectoryRemoved -> output.json.encodeToJsonElement(ChatWorkingDirectoryRemovedAction.serializer(), value.value) is StateActionSessionInputNeededSet -> output.json.encodeToJsonElement(SessionInputNeededSetAction.serializer(), value.value) is StateActionSessionInputNeededRemoved -> output.json.encodeToJsonElement(SessionInputNeededRemovedAction.serializer(), value.value) is StateActionChatPendingMessageSet -> output.json.encodeToJsonElement(ChatPendingMessageSetAction.serializer(), value.value) diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt index a4c0a5ed5..96ecb2c4a 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt @@ -369,9 +369,44 @@ data class CreateSessionParams( */ val provider: String? = null, /** - * Working directory for the session + * The working directories the session's agent is granted tool access to. + * A session may span multiple directories; they are equal peers except when + * the agent advertises + * {@link MultipleWorkingDirectoriesCapability.requiresPrimary}, in which case + * one of them should be designated the primary via + * {@link primaryWorkingDirectory}. + * + * A client MUST NOT supply more than one entry unless the agent advertises + * {@link AgentCapabilities.multipleWorkingDirectories}; a server without that + * capability treats only the first entry as the session's working directory + * and ignores the rest. Dispatch `session/workingDirectorySet` / + * `session/workingDirectoryRemoved` to change the set after the session has + * started. + * + * Ignored for forked sessions — a fork inherits its working directories + * from the source session identified by `fork`. */ - val workingDirectory: String? = null, + val workingDirectories: List? = null, + /** + * The primary working directory for the session's **default chat**. + * + * A session has no primary of its own — primary is a per-chat notion (see + * {@link ChatState.primaryWorkingDirectory}). But `createSession` implicitly + * creates the session's default chat, and there is no separate `createChat` + * call to carry that chat's create-time fields. This field is therefore the + * only place a client can designate the **default chat's** primary at birth; + * it is copied into that chat's read-only `primaryWorkingDirectory`. For any + * non-default chat, pass {@link CreateChatParams.primaryWorkingDirectory} + * instead. + * + * When set, it MUST be one of {@link workingDirectories}. A client SHOULD + * supply this when the agent advertises + * {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY + * reject creation that omits it, or fall back to the first entry of + * `workingDirectories`. Ignored for forked sessions (a fork inherits the + * source session's chats and their primaries). + */ + val primaryWorkingDirectory: String? = null, /** * Fork from an existing session. The new session is populated with content * from the source session up to and including the specified turn's response. @@ -443,7 +478,30 @@ data class CreateChatParams( /** * Optional source chat and turn to fork from. */ - val source: ChatForkSource? = null + val source: ChatForkSource? = null, + /** + * Initial working-directory subset for this chat. Every entry MUST be + * present in the owning session's `workingDirectories`; the server MUST + * reject any entry that is not. When absent, the chat inherits the full + * session set. Forked chats (`source`) inherit the source chat's + * `workingDirectories`; this field is ignored for forked chats. + * + * A client MUST NOT supply this field unless the agent advertises + * {@link AgentCapabilities.multipleWorkingDirectories}. + */ + val workingDirectories: List? = null, + /** + * The chat's primary working directory — the distinguished root this chat is + * centered on. When set, it MUST be one of the chat's effective working + * directories ({@link workingDirectories}, or the session's set when that is + * omitted). A client SHOULD supply this when the agent advertises + * {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY + * reject creation that omits it, or fall back to the first of the chat's + * directories. Fixed at creation and reported (read-only) on + * {@link ChatState.primaryWorkingDirectory}. Ignored for forked chats (a fork + * inherits the source chat's primary). + */ + val primaryWorkingDirectory: String? = null ) @Serializable diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Notifications.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Notifications.generated.kt index 202766a37..392401e94 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Notifications.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Notifications.generated.kt @@ -196,12 +196,16 @@ data class PartialSessionSummary( */ val project: ProjectInfo? = null, /** - * The default working directory URI for this session. Individual chats - * MAY override via {@link ChatSummary.workingDirectory | their own - * `workingDirectory`}; this field acts as the fallback for any chat that - * does not. - */ - val workingDirectory: String? = null, + * The working directories the session's agent has tool access to, as + * maintained by the `session/workingDirectorySet` / + * `session/workingDirectoryRemoved` actions. Directories are **equal peers** — + * the session has no primary. Individual chats MAY restrict to a subset via + * {@link ChatSummary.workingDirectories | their own `workingDirectories`} and + * designate one of their own directories as primary (see + * {@link ChatState.primaryWorkingDirectory}); a chat that sets no subset + * operates against this full set. + */ + val workingDirectories: List? = null, /** * Lightweight summary of this session's inline annotations channel * (`ahp-session://annotations`). Surfaced so badge UI can render 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 a84af21f8..1607814e3 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 @@ -952,7 +952,18 @@ data class AgentCapabilities( * session starts with. An empty object `{}` advertises multi-chat without * forking; set {@link MultipleChatsCapability.fork} to also allow forking. */ - val multipleChats: MultipleChatsCapability? = null + val multipleChats: MultipleChatsCapability? = null, + /** + * The session's agent can be granted tool access to more than one working + * directory. The directories are treated as equal peers except where the + * agent advertises {@link MultipleWorkingDirectoriesCapability.requiresPrimary} + * (some backends need one directory designated as a primary root). + * + * When absent, clients MUST NOT mutate a session's or chat's working-directory + * set and MUST NOT set more than one entry in + * {@link CreateSessionParams.workingDirectories}. + */ + val multipleWorkingDirectories: MultipleWorkingDirectoriesCapability? = null ) @Serializable @@ -965,6 +976,25 @@ data class MultipleChatsCapability( val fork: Boolean? = null ) +@Serializable +data class MultipleWorkingDirectoriesCapability( + /** + * The agent requires each chat to designate one of its working directories as + * the **primary** — a distinguished root the chat is centered on (e.g. the + * agent's process root for that chat, the default location for relative + * paths). Primary is a **per-chat** notion, fixed at chat creation. When + * `true`, a client SHOULD supply {@link CreateChatParams.primaryWorkingDirectory} + * (and {@link CreateSessionParams.primaryWorkingDirectory}, which seeds the + * session's default chat); a host MAY reject creation that omits it, or fall + * back to the first entry of the chat's working directories. The chosen + * primary is reported (read-only) on {@link ChatState.primaryWorkingDirectory}. + * + * When absent or `false`, the agent has no primary — all directories are + * equal peers and clients need not designate one. + */ + val requiresPrimary: Boolean? = null +) + @Serializable data class SessionModelInfo( /** @@ -1152,15 +1182,34 @@ data class ChatState( */ val interactivity: ChatInteractivity? = null, /** - * Optional per-chat working directory. + * The subset of the session's + * {@link SessionState.workingDirectories | `workingDirectories`} that this + * chat's agent has tool access to. Every entry MUST be present in the owning + * session's `workingDirectories`; servers MUST reject a + * `chat/workingDirectorySet` action that violates this constraint. + * + * When absent, the chat inherits the full session set. When present but empty + * (not recommended), the chat has no working-directory tool access at all. + * + * Dispatch `chat/workingDirectorySet` / `chat/workingDirectoryRemoved` to + * update the subset on a running chat. + */ + val workingDirectories: List? = null, + /** + * The chat's primary working directory — the distinguished root this chat is + * centered on (e.g. the agent's process root for this chat, the default + * location for relative paths). MUST be one of this chat's effective working + * directories ({@link workingDirectories}, or the session's set when that is + * absent). Present when the agent advertises + * {@link MultipleWorkingDirectoriesCapability.requiresPrimary}. * - * If absent, the chat inherits - * {@link SessionState.workingDirectory | the session's working directory}. - * Hosts MAY override this for individual chats — for example, to give a - * subordinate chat its own git worktree so multiple chats in a session can - * make independent edits that the orchestrator later merges back. + * **Read-only and fixed at creation.** It is set from + * {@link CreateChatParams.primaryWorkingDirectory} (or, for the session's + * default chat, {@link CreateSessionParams.primaryWorkingDirectory}) and does + * not change over the chat's lifetime — there is no action to mutate it, and + * it does not participate in `session/chatUpdated`. */ - val workingDirectory: String? = null, + val primaryWorkingDirectory: String? = null, /** * Completed turns */ @@ -1242,13 +1291,15 @@ data class ChatSummary( */ val interactivity: ChatInteractivity? = null, /** - * Optional per-chat working directory. - * - * If absent, the chat inherits - * {@link SessionSummary.workingDirectory | the session's working directory}. - * See {@link ChatState.workingDirectory} for usage notes. + * The subset of the session's working directories this chat uses. + * See {@link ChatState.workingDirectories} for the full semantics. + */ + val workingDirectories: List? = null, + /** + * The chat's primary working directory. + * See {@link ChatState.primaryWorkingDirectory} for the full semantics. */ - val workingDirectory: String? = null + val primaryWorkingDirectory: String? = null ) @Serializable @@ -1274,12 +1325,16 @@ data class SessionState( */ val project: ProjectInfo? = null, /** - * The default working directory URI for this session. Individual chats - * MAY override via {@link ChatSummary.workingDirectory | their own - * `workingDirectory`}; this field acts as the fallback for any chat that - * does not. + * The working directories the session's agent has tool access to, as + * maintained by the `session/workingDirectorySet` / + * `session/workingDirectoryRemoved` actions. Directories are **equal peers** — + * the session has no primary. Individual chats MAY restrict to a subset via + * {@link ChatSummary.workingDirectories | their own `workingDirectories`} and + * designate one of their own directories as primary (see + * {@link ChatState.primaryWorkingDirectory}); a chat that sets no subset + * operates against this full set. */ - val workingDirectory: String? = null, + val workingDirectories: List? = null, /** * Lightweight summary of this session's inline annotations channel * (`ahp-session://annotations`). Surfaced so badge UI can render @@ -1378,7 +1433,7 @@ data class SessionState( * * Clients MAY look for well-known keys here to provide enhanced UI. * For example, a `git` key may provide extra git metadata about the session's - * workingDirectory. + * working directories. */ @SerialName("_meta") val meta: Map? = null @@ -1543,12 +1598,16 @@ data class SessionSummary( */ val project: ProjectInfo? = null, /** - * The default working directory URI for this session. Individual chats - * MAY override via {@link ChatSummary.workingDirectory | their own - * `workingDirectory`}; this field acts as the fallback for any chat that - * does not. + * The working directories the session's agent has tool access to, as + * maintained by the `session/workingDirectorySet` / + * `session/workingDirectoryRemoved` actions. Directories are **equal peers** — + * the session has no primary. Individual chats MAY restrict to a subset via + * {@link ChatSummary.workingDirectories | their own `workingDirectories`} and + * designate one of their own directories as primary (see + * {@link ChatState.primaryWorkingDirectory}); a chat that sets no subset + * operates against this full set. */ - val workingDirectory: String? = null, + val workingDirectories: List? = null, /** * Lightweight summary of this session's inline annotations channel * (`ahp-session://annotations`). Surfaced so badge UI can render diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt index 97bcc076b..bab670ef8 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt @@ -5,7 +5,7 @@ package com.microsoft.agenthostprotocol.generated /** * Current protocol version (SemVer `MAJOR.MINOR.PATCH`). */ -public const val PROTOCOL_VERSION: String = "0.6.1" +public const val PROTOCOL_VERSION: String = "0.7.0" /** * Every protocol version this library is willing to negotiate, ordered @@ -16,7 +16,7 @@ public const val PROTOCOL_VERSION: String = "0.6.1" * protocol versions if the host doesn't accept the newest one. */ public val SUPPORTED_PROTOCOL_VERSIONS: List = listOf( - "0.6.1", + "0.7.0", "0.6.0", "0.5.2", "0.5.1", diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index 1bf06fa79..aa2ad448d 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -76,6 +76,10 @@ pub enum ActionType { ChatError, #[serde(rename = "chat/activityChanged")] ChatActivityChanged, + #[serde(rename = "chat/workingDirectorySet")] + ChatWorkingDirectorySet, + #[serde(rename = "chat/workingDirectoryRemoved")] + ChatWorkingDirectoryRemoved, #[serde(rename = "session/titleChanged")] SessionTitleChanged, #[serde(rename = "chat/usage")] @@ -88,6 +92,10 @@ pub enum ActionType { SessionActiveClientSet, #[serde(rename = "session/activeClientRemoved")] SessionActiveClientRemoved, + #[serde(rename = "session/workingDirectorySet")] + SessionWorkingDirectorySet, + #[serde(rename = "session/workingDirectoryRemoved")] + SessionWorkingDirectoryRemoved, #[serde(rename = "session/inputNeededSet")] SessionInputNeededSet, #[serde(rename = "session/inputNeededRemoved")] @@ -926,6 +934,65 @@ pub struct SessionActiveClientRemovedAction { pub client_id: String, } +/// A working directory was added to the session's +/// {@link SessionState.workingDirectories} set. +/// +/// Membership semantics keyed by the directory URI: the reducer appends +/// `directory` when the set does not already contain it (creating the set if +/// absent) and is a no-op when it is already present. Only valid when the agent +/// advertises {@link AgentCapabilities.multipleWorkingDirectories}. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionWorkingDirectorySetAction { + /// The working directory to grant the session's agent tool access to. + pub directory: Uri, +} + +/// A working directory was removed from the session's +/// {@link SessionState.workingDirectories} set. +/// +/// Removes `directory` from the set; a no-op when it is not present. There is no +/// atomic backend "remove one" primitive — a host reconfigures its agent to the +/// reduced set — so this action is safe to model as idempotent. A host MAY +/// decline to apply the removal (e.g. a directory still designated as some +/// chat's {@link ChatState.primaryWorkingDirectory | primary}); it then leaves +/// the set unchanged. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionWorkingDirectoryRemovedAction { + /// The working directory to revoke the session's agent tool access to. + pub directory: Uri, +} + +/// A working directory was added to this chat's +/// {@link ChatState.workingDirectories} subset. +/// +/// Membership semantics keyed by the directory URI: the reducer appends +/// `directory` when the chat's subset does not already contain it (creating the +/// subset if absent) and is a no-op when it is already present. `directory` MUST +/// be one of the owning session's {@link SessionState.workingDirectories}; a host +/// MUST reject a directory that is not. Only valid when the agent advertises +/// {@link AgentCapabilities.multipleWorkingDirectories}. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatWorkingDirectorySetAction { + /// The working directory to add to this chat's subset. + pub directory: Uri, +} + +/// A working directory was removed from this chat's +/// {@link ChatState.workingDirectories} subset. +/// +/// Removes `directory` from the chat's subset; a no-op when it is not present. +/// Idempotent, mirroring `session/workingDirectoryRemoved`. Only affects the +/// chat's subset — the directory remains in the session's set. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatWorkingDirectoryRemovedAction { + /// The working directory to remove from this chat's subset. + pub directory: Uri, +} + /// A session-level input request was added or updated. /// /// Upsert semantics keyed by {@link SessionInputRequest.id | `request.id`}: the @@ -1691,13 +1758,14 @@ pub struct PartialChatSummary { /// compatibility. #[serde(default, skip_serializing_if = "Option::is_none")] pub interactivity: Option, - /// Optional per-chat working directory. - /// - /// If absent, the chat inherits - /// {@link SessionSummary.workingDirectory | the session's working directory}. - /// See {@link ChatState.workingDirectory} for usage notes. + /// The subset of the session's working directories this chat uses. + /// See {@link ChatState.workingDirectories} for the full semantics. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub working_directories: Option>, + /// The chat's primary working directory. + /// See {@link ChatState.primaryWorkingDirectory} for the full semantics. #[serde(default, skip_serializing_if = "Option::is_none")] - pub working_directory: Option, + pub primary_working_directory: Option, } // ─── StateAction Union ─────────────────────────────────────────────── @@ -1776,6 +1844,14 @@ pub enum StateAction { SessionActiveClientSet(SessionActiveClientSetAction), #[serde(rename = "session/activeClientRemoved")] SessionActiveClientRemoved(SessionActiveClientRemovedAction), + #[serde(rename = "session/workingDirectorySet")] + SessionWorkingDirectorySet(SessionWorkingDirectorySetAction), + #[serde(rename = "session/workingDirectoryRemoved")] + SessionWorkingDirectoryRemoved(SessionWorkingDirectoryRemovedAction), + #[serde(rename = "chat/workingDirectorySet")] + ChatWorkingDirectorySet(ChatWorkingDirectorySetAction), + #[serde(rename = "chat/workingDirectoryRemoved")] + ChatWorkingDirectoryRemoved(ChatWorkingDirectoryRemovedAction), #[serde(rename = "session/inputNeededSet")] SessionInputNeededSet(Box), #[serde(rename = "session/inputNeededRemoved")] diff --git a/clients/rust/crates/ahp-types/src/commands.rs b/clients/rust/crates/ahp-types/src/commands.rs index 326c50603..cc4e7a4c5 100644 --- a/clients/rust/crates/ahp-types/src/commands.rs +++ b/clients/rust/crates/ahp-types/src/commands.rs @@ -391,9 +391,43 @@ pub struct CreateSessionParams { /// Agent provider ID #[serde(default, skip_serializing_if = "Option::is_none")] pub provider: Option, - /// Working directory for the session + /// The working directories the session's agent is granted tool access to. + /// A session may span multiple directories; they are equal peers except when + /// the agent advertises + /// {@link MultipleWorkingDirectoriesCapability.requiresPrimary}, in which case + /// one of them should be designated the primary via + /// {@link primaryWorkingDirectory}. + /// + /// A client MUST NOT supply more than one entry unless the agent advertises + /// {@link AgentCapabilities.multipleWorkingDirectories}; a server without that + /// capability treats only the first entry as the session's working directory + /// and ignores the rest. Dispatch `session/workingDirectorySet` / + /// `session/workingDirectoryRemoved` to change the set after the session has + /// started. + /// + /// Ignored for forked sessions — a fork inherits its working directories + /// from the source session identified by `fork`. #[serde(default, skip_serializing_if = "Option::is_none")] - pub working_directory: Option, + pub working_directories: Option>, + /// The primary working directory for the session's **default chat**. + /// + /// A session has no primary of its own — primary is a per-chat notion (see + /// {@link ChatState.primaryWorkingDirectory}). But `createSession` implicitly + /// creates the session's default chat, and there is no separate `createChat` + /// call to carry that chat's create-time fields. This field is therefore the + /// only place a client can designate the **default chat's** primary at birth; + /// it is copied into that chat's read-only `primaryWorkingDirectory`. For any + /// non-default chat, pass {@link CreateChatParams.primaryWorkingDirectory} + /// instead. + /// + /// When set, it MUST be one of {@link workingDirectories}. A client SHOULD + /// supply this when the agent advertises + /// {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY + /// reject creation that omits it, or fall back to the first entry of + /// `workingDirectories`. Ignored for forked sessions (a fork inherits the + /// source session's chats and their primaries). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub primary_working_directory: Option, /// Fork from an existing session. The new session is populated with content /// from the source session up to and including the specified turn's response. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -458,6 +492,27 @@ pub struct CreateChatParams { /// Optional source chat and turn to fork from. #[serde(default, skip_serializing_if = "Option::is_none")] pub source: Option, + /// Initial working-directory subset for this chat. Every entry MUST be + /// present in the owning session's `workingDirectories`; the server MUST + /// reject any entry that is not. When absent, the chat inherits the full + /// session set. Forked chats (`source`) inherit the source chat's + /// `workingDirectories`; this field is ignored for forked chats. + /// + /// A client MUST NOT supply this field unless the agent advertises + /// {@link AgentCapabilities.multipleWorkingDirectories}. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub working_directories: Option>, + /// The chat's primary working directory — the distinguished root this chat is + /// centered on. When set, it MUST be one of the chat's effective working + /// directories ({@link workingDirectories}, or the session's set when that is + /// omitted). A client SHOULD supply this when the agent advertises + /// {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY + /// reject creation that omits it, or fall back to the first of the chat's + /// directories. Fixed at creation and reported (read-only) on + /// {@link ChatState.primaryWorkingDirectory}. Ignored for forked chats (a fork + /// inherits the source chat's primary). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub primary_working_directory: Option, } /// Disposes a chat and cleans up server-side resources. diff --git a/clients/rust/crates/ahp-types/src/notifications.rs b/clients/rust/crates/ahp-types/src/notifications.rs index 5621f1a52..e56151bfc 100644 --- a/clients/rust/crates/ahp-types/src/notifications.rs +++ b/clients/rust/crates/ahp-types/src/notifications.rs @@ -248,12 +248,16 @@ pub struct PartialSessionSummary { /// Server-owned project for this session #[serde(default, skip_serializing_if = "Option::is_none")] pub project: Option, - /// The default working directory URI for this session. Individual chats - /// MAY override via {@link ChatSummary.workingDirectory | their own - /// `workingDirectory`}; this field acts as the fallback for any chat that - /// does not. + /// The working directories the session's agent has tool access to, as + /// maintained by the `session/workingDirectorySet` / + /// `session/workingDirectoryRemoved` actions. Directories are **equal peers** — + /// the session has no primary. Individual chats MAY restrict to a subset via + /// {@link ChatSummary.workingDirectories | their own `workingDirectories`} and + /// designate one of their own directories as primary (see + /// {@link ChatState.primaryWorkingDirectory}); a chat that sets no subset + /// operates against this full set. #[serde(default, skip_serializing_if = "Option::is_none")] - pub working_directory: Option, + pub working_directories: Option>, /// Lightweight summary of this session's inline annotations channel /// (`ahp-session://annotations`). Surfaced so badge UI can render /// annotation / entry counts without subscribing. Absent when the session diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index 1bc1f6635..18ca75394 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -803,6 +803,16 @@ pub struct AgentCapabilities { /// forking; set {@link MultipleChatsCapability.fork} to also allow forking. #[serde(default, skip_serializing_if = "Option::is_none")] pub multiple_chats: Option, + /// The session's agent can be granted tool access to more than one working + /// directory. The directories are treated as equal peers except where the + /// agent advertises {@link MultipleWorkingDirectoriesCapability.requiresPrimary} + /// (some backends need one directory designated as a primary root). + /// + /// When absent, clients MUST NOT mutate a session's or chat's working-directory + /// set and MUST NOT set more than one entry in + /// {@link CreateSessionParams.workingDirectories}. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub multiple_working_directories: Option, } /// Options for the {@link AgentCapabilities.multipleChats} capability. @@ -816,6 +826,26 @@ pub struct MultipleChatsCapability { pub fork: Option, } +/// Options for the {@link AgentCapabilities.multipleWorkingDirectories} capability. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct MultipleWorkingDirectoriesCapability { + /// The agent requires each chat to designate one of its working directories as + /// the **primary** — a distinguished root the chat is centered on (e.g. the + /// agent's process root for that chat, the default location for relative + /// paths). Primary is a **per-chat** notion, fixed at chat creation. When + /// `true`, a client SHOULD supply {@link CreateChatParams.primaryWorkingDirectory} + /// (and {@link CreateSessionParams.primaryWorkingDirectory}, which seeds the + /// session's default chat); a host MAY reject creation that omits it, or fall + /// back to the first entry of the chat's working directories. The chosen + /// primary is reported (read-only) on {@link ChatState.primaryWorkingDirectory}. + /// + /// When absent or `false`, the agent has no primary — all directories are + /// equal peers and clients need not designate one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requires_primary: Option, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionModelInfo { @@ -996,15 +1026,33 @@ pub struct ChatState { /// compatibility. #[serde(default, skip_serializing_if = "Option::is_none")] pub interactivity: Option, - /// Optional per-chat working directory. + /// The subset of the session's + /// {@link SessionState.workingDirectories | `workingDirectories`} that this + /// chat's agent has tool access to. Every entry MUST be present in the owning + /// session's `workingDirectories`; servers MUST reject a + /// `chat/workingDirectorySet` action that violates this constraint. + /// + /// When absent, the chat inherits the full session set. When present but empty + /// (not recommended), the chat has no working-directory tool access at all. /// - /// If absent, the chat inherits - /// {@link SessionState.workingDirectory | the session's working directory}. - /// Hosts MAY override this for individual chats — for example, to give a - /// subordinate chat its own git worktree so multiple chats in a session can - /// make independent edits that the orchestrator later merges back. + /// Dispatch `chat/workingDirectorySet` / `chat/workingDirectoryRemoved` to + /// update the subset on a running chat. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub working_directories: Option>, + /// The chat's primary working directory — the distinguished root this chat is + /// centered on (e.g. the agent's process root for this chat, the default + /// location for relative paths). MUST be one of this chat's effective working + /// directories ({@link workingDirectories}, or the session's set when that is + /// absent). Present when the agent advertises + /// {@link MultipleWorkingDirectoriesCapability.requiresPrimary}. + /// + /// **Read-only and fixed at creation.** It is set from + /// {@link CreateChatParams.primaryWorkingDirectory} (or, for the session's + /// default chat, {@link CreateSessionParams.primaryWorkingDirectory}) and does + /// not change over the chat's lifetime — there is no action to mutate it, and + /// it does not participate in `session/chatUpdated`. #[serde(default, skip_serializing_if = "Option::is_none")] - pub working_directory: Option, + pub primary_working_directory: Option, /// Completed turns pub turns: Vec, /// Cursor for loading older completed turns into this chat state. @@ -1069,13 +1117,14 @@ pub struct ChatSummary { /// compatibility. #[serde(default, skip_serializing_if = "Option::is_none")] pub interactivity: Option, - /// Optional per-chat working directory. - /// - /// If absent, the chat inherits - /// {@link SessionSummary.workingDirectory | the session's working directory}. - /// See {@link ChatState.workingDirectory} for usage notes. + /// The subset of the session's working directories this chat uses. + /// See {@link ChatState.workingDirectories} for the full semantics. #[serde(default, skip_serializing_if = "Option::is_none")] - pub working_directory: Option, + pub working_directories: Option>, + /// The chat's primary working directory. + /// See {@link ChatState.primaryWorkingDirectory} for the full semantics. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub primary_working_directory: Option, } /// Full state for a single session, loaded when a client subscribes to the session's URI. @@ -1100,12 +1149,16 @@ pub struct SessionState { /// Server-owned project for this session #[serde(default, skip_serializing_if = "Option::is_none")] pub project: Option, - /// The default working directory URI for this session. Individual chats - /// MAY override via {@link ChatSummary.workingDirectory | their own - /// `workingDirectory`}; this field acts as the fallback for any chat that - /// does not. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub working_directory: Option, + /// The working directories the session's agent has tool access to, as + /// maintained by the `session/workingDirectorySet` / + /// `session/workingDirectoryRemoved` actions. Directories are **equal peers** — + /// the session has no primary. Individual chats MAY restrict to a subset via + /// {@link ChatSummary.workingDirectories | their own `workingDirectories`} and + /// designate one of their own directories as primary (see + /// {@link ChatState.primaryWorkingDirectory}); a chat that sets no subset + /// operates against this full set. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub working_directories: Option>, /// Lightweight summary of this session's inline annotations channel /// (`ahp-session://annotations`). Surfaced so badge UI can render /// annotation / entry counts without subscribing. Absent when the session @@ -1189,7 +1242,7 @@ pub struct SessionState { /// /// Clients MAY look for well-known keys here to provide enhanced UI. /// For example, a `git` key may provide extra git metadata about the session's - /// workingDirectory. + /// working directories. #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, } @@ -1358,9 +1411,9 @@ pub struct SessionToolAuthenticationRequest { /// chat currently driving the promoted status bits when a non-default chat /// wins (e.g. the chat that raised `InputNeeded`). /// - `modifiedAt`: the max of all chats' `modifiedAt`. -/// - `workingDirectory`: the session-level **default**. Individual chats MAY -/// override via {@link ChatSummary.workingDirectory}; aggregating these up -/// is meaningless and SHOULD NOT be attempted. +/// - `workingDirectories`: the session-level set. Individual chats MAY restrict +/// to a subset via {@link ChatSummary.workingDirectories}; aggregating these +/// up is meaningless and SHOULD NOT be attempted. /// - `changes`: optional roll-up across all chats. Producers MAY sum the /// per-chat changeset stats or report the most expensive chat's stats — /// whichever is cheaper for the host to compute. @@ -1383,12 +1436,16 @@ pub struct SessionSummary { /// Server-owned project for this session #[serde(default, skip_serializing_if = "Option::is_none")] pub project: Option, - /// The default working directory URI for this session. Individual chats - /// MAY override via {@link ChatSummary.workingDirectory | their own - /// `workingDirectory`}; this field acts as the fallback for any chat that - /// does not. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub working_directory: Option, + /// The working directories the session's agent has tool access to, as + /// maintained by the `session/workingDirectorySet` / + /// `session/workingDirectoryRemoved` actions. Directories are **equal peers** — + /// the session has no primary. Individual chats MAY restrict to a subset via + /// {@link ChatSummary.workingDirectories | their own `workingDirectories`} and + /// designate one of their own directories as primary (see + /// {@link ChatState.primaryWorkingDirectory}); a chat that sets no subset + /// operates against this full set. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub working_directories: Option>, /// Lightweight summary of this session's inline annotations channel /// (`ahp-session://annotations`). Surfaced so badge UI can render /// annotation / entry counts without subscribing. Absent when the session diff --git a/clients/rust/crates/ahp-types/src/version.rs b/clients/rust/crates/ahp-types/src/version.rs index edc46573f..d05243be0 100644 --- a/clients/rust/crates/ahp-types/src/version.rs +++ b/clients/rust/crates/ahp-types/src/version.rs @@ -5,7 +5,7 @@ #![allow(missing_docs)] /// Current protocol version (SemVer `MAJOR.MINOR.PATCH`). -pub const PROTOCOL_VERSION: &str = "0.6.1"; +pub const PROTOCOL_VERSION: &str = "0.7.0"; /// Every protocol version this crate is willing to negotiate, ordered /// most-preferred-first. The first entry equals [`PROTOCOL_VERSION`]. @@ -13,4 +13,4 @@ pub const PROTOCOL_VERSION: &str = "0.6.1"; /// Consumers building `InitializeParams` should pass this slice (or a /// derived `Vec`) so the same client binary can fall back to /// older protocol versions if the host doesn't accept the newest one. -pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &["0.6.1", "0.6.0", "0.5.2", "0.5.1"]; +pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &["0.7.0", "0.6.0", "0.5.2", "0.5.1"]; diff --git a/clients/rust/crates/ahp/src/hosts/runtime.rs b/clients/rust/crates/ahp/src/hosts/runtime.rs index ecd066475..35216671f 100644 --- a/clients/rust/crates/ahp/src/hosts/runtime.rs +++ b/clients/rust/crates/ahp/src/hosts/runtime.rs @@ -724,8 +724,8 @@ fn apply_summary_changes( if let Some(v) = &changes.project { existing.project = Some(v.clone()); } - if let Some(v) = &changes.working_directory { - existing.working_directory = Some(v.clone()); + if let Some(v) = &changes.working_directories { + existing.working_directories = Some(v.clone()); } if let Some(v) = &changes.annotations { existing.annotations = Some(v.clone()); diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs index 2a6553450..4dfb4f985 100644 --- a/clients/rust/crates/ahp/src/reducers.rs +++ b/clients/rust/crates/ahp/src/reducers.rs @@ -667,8 +667,8 @@ pub fn apply_action_to_session(state: &mut SessionState, action: &StateAction) - if let Some(origin) = &a.changes.origin { chat.origin = Some(origin.clone()); } - if let Some(working_directory) = &a.changes.working_directory { - chat.working_directory = Some(working_directory.clone()); + if let Some(working_directories) = &a.changes.working_directories { + chat.working_directories = Some(working_directories.clone()); } ReduceOutcome::Applied } @@ -740,6 +740,24 @@ pub fn apply_action_to_session(state: &mut SessionState, action: &StateAction) - state.active_clients.remove(idx); ReduceOutcome::Applied } + StateAction::SessionWorkingDirectorySet(a) => { + let list = state.working_directories.get_or_insert_with(Vec::new); + if list.contains(&a.directory) { + return ReduceOutcome::NoOp; + } + list.push(a.directory.clone()); + ReduceOutcome::Applied + } + StateAction::SessionWorkingDirectoryRemoved(a) => { + let Some(list) = state.working_directories.as_mut() else { + return ReduceOutcome::NoOp; + }; + let Some(idx) = list.iter().position(|d| *d == a.directory) else { + return ReduceOutcome::NoOp; + }; + list.remove(idx); + ReduceOutcome::Applied + } StateAction::SessionInputNeededSet(a) => { let Some(action_id) = session_input_request_id(&a.request) else { return ReduceOutcome::NoOp; @@ -947,6 +965,24 @@ pub fn apply_action_to_chat(state: &mut ChatState, action: &StateAction) -> Redu state.activity = a.activity.clone(); ReduceOutcome::Applied } + StateAction::ChatWorkingDirectorySet(a) => { + let list = state.working_directories.get_or_insert_with(Vec::new); + if list.contains(&a.directory) { + return ReduceOutcome::NoOp; + } + list.push(a.directory.clone()); + ReduceOutcome::Applied + } + StateAction::ChatWorkingDirectoryRemoved(a) => { + let Some(list) = state.working_directories.as_mut() else { + return ReduceOutcome::NoOp; + }; + let Some(idx) = list.iter().position(|d| *d == a.directory) else { + return ReduceOutcome::NoOp; + }; + list.remove(idx); + ReduceOutcome::Applied + } StateAction::ChatToolCallStart(a) => { let Some(active) = state.active_turn.as_mut() else { return ReduceOutcome::NoOp; @@ -1912,7 +1948,7 @@ mod tests { status: SessionStatus::Idle.bits(), activity: None, project: None, - working_directory: None, + working_directories: None, annotations: None, lifecycle: SessionLifecycle::Creating, creation_error: None, @@ -1937,7 +1973,8 @@ mod tests { modified_at: "1970-01-01T00:00:00.000Z".into(), origin: None, interactivity: None, - working_directory: None, + working_directories: None, + primary_working_directory: None, turns: Vec::new(), turns_next_cursor: None, active_turn: None, @@ -2033,7 +2070,8 @@ mod tests { modified_at: "1970-01-01T00:00:00.000Z".into(), origin: None, interactivity: None, - working_directory: None, + working_directories: None, + primary_working_directory: None, }; let added = StateAction::SessionChatAdded(ahp_types::actions::SessionChatAddedAction { summary: chat.clone(), diff --git a/clients/rust/crates/ahp/tests/hosts.rs b/clients/rust/crates/ahp/tests/hosts.rs index 2e1f4b06f..9031a7658 100644 --- a/clients/rust/crates/ahp/tests/hosts.rs +++ b/clients/rust/crates/ahp/tests/hosts.rs @@ -1039,7 +1039,7 @@ fn make_summary(uri: &str, title: &str, modified_at: i64) -> ahp_types::state::S created_at: "1970-01-01T00:00:00.000Z".into(), modified_at: modified, project: None, - working_directory: None, + working_directories: None, changes: None, annotations: None, meta: None, diff --git a/clients/rust/crates/ahp/tests/multi_host_state_mirror.rs b/clients/rust/crates/ahp/tests/multi_host_state_mirror.rs index 5f8ad2dfe..23969f940 100644 --- a/clients/rust/crates/ahp/tests/multi_host_state_mirror.rs +++ b/clients/rust/crates/ahp/tests/multi_host_state_mirror.rs @@ -51,7 +51,7 @@ fn session_state(title: &str, _resource: &str) -> SessionState { status: SessionStatus::Idle.bits(), activity: None, project: None, - working_directory: None, + working_directories: None, annotations: None, lifecycle: SessionLifecycle::Ready, creation_error: None, @@ -354,7 +354,7 @@ fn non_action_event_is_ignored() { created_at: "1970-01-01T00:00:00.000Z".into(), modified_at: "1970-01-01T00:00:00.000Z".into(), project: None, - working_directory: None, + working_directories: None, changes: None, annotations: None, meta: None, diff --git a/clients/rust/release-metadata.json b/clients/rust/release-metadata.json index 3744ab4a4..1fba6f631 100644 --- a/clients/rust/release-metadata.json +++ b/clients/rust/release-metadata.json @@ -2,7 +2,7 @@ "client": "rust", "packageVersion": "0.6.0", "supportedProtocolVersions": [ - "0.6.1", + "0.7.0", "0.6.0", "0.5.2", "0.5.1" diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift index 596d502a8..2e1b3f564 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift @@ -30,12 +30,16 @@ public enum ActionType: String, Codable, Sendable { case chatTurnCancelled = "chat/turnCancelled" case chatError = "chat/error" case chatActivityChanged = "chat/activityChanged" + case chatWorkingDirectorySet = "chat/workingDirectorySet" + case chatWorkingDirectoryRemoved = "chat/workingDirectoryRemoved" case sessionTitleChanged = "session/titleChanged" case chatUsage = "chat/usage" case chatReasoning = "chat/reasoning" case sessionServerToolsChanged = "session/serverToolsChanged" case sessionActiveClientSet = "session/activeClientSet" case sessionActiveClientRemoved = "session/activeClientRemoved" + case sessionWorkingDirectorySet = "session/workingDirectorySet" + case sessionWorkingDirectoryRemoved = "session/workingDirectoryRemoved" case sessionInputNeededSet = "session/inputNeededSet" case sessionInputNeededRemoved = "session/inputNeededRemoved" case chatPendingMessageSet = "chat/pendingMessageSet" @@ -1112,6 +1116,62 @@ public struct SessionActiveClientRemovedAction: Codable, Sendable { } } +public struct SessionWorkingDirectorySetAction: Codable, Sendable { + public var type: ActionType + /// The working directory to grant the session's agent tool access to. + public var directory: String + + public init( + type: ActionType, + directory: String + ) { + self.type = type + self.directory = directory + } +} + +public struct SessionWorkingDirectoryRemovedAction: Codable, Sendable { + public var type: ActionType + /// The working directory to revoke the session's agent tool access to. + public var directory: String + + public init( + type: ActionType, + directory: String + ) { + self.type = type + self.directory = directory + } +} + +public struct ChatWorkingDirectorySetAction: Codable, Sendable { + public var type: ActionType + /// The working directory to add to this chat's subset. + public var directory: String + + public init( + type: ActionType, + directory: String + ) { + self.type = type + self.directory = directory + } +} + +public struct ChatWorkingDirectoryRemovedAction: Codable, Sendable { + public var type: ActionType + /// The working directory to remove from this chat's subset. + public var directory: String + + public init( + type: ActionType, + directory: String + ) { + self.type = type + self.directory = directory + } +} + public struct SessionInputNeededSetAction: Codable, Sendable { public var type: ActionType /// The input request to add or update, matched by `id`. @@ -1916,12 +1976,12 @@ public struct PartialChatSummary: Codable, Sendable { /// Absence defaults to {@link ChatInteractivity.Full} for backward /// compatibility. public var interactivity: ChatInteractivity? - /// Optional per-chat working directory. - /// - /// If absent, the chat inherits - /// {@link SessionSummary.workingDirectory | the session's working directory}. - /// See {@link ChatState.workingDirectory} for usage notes. - public var workingDirectory: String? + /// The subset of the session's working directories this chat uses. + /// See {@link ChatState.workingDirectories} for the full semantics. + public var workingDirectories: [String]? + /// The chat's primary working directory. + /// See {@link ChatState.primaryWorkingDirectory} for the full semantics. + public var primaryWorkingDirectory: String? public init( resource: String? = nil, @@ -1931,7 +1991,8 @@ public struct PartialChatSummary: Codable, Sendable { modifiedAt: String? = nil, origin: ChatOrigin? = nil, interactivity: ChatInteractivity? = nil, - workingDirectory: String? = nil + workingDirectories: [String]? = nil, + primaryWorkingDirectory: String? = nil ) { self.resource = resource self.title = title @@ -1940,7 +2001,8 @@ public struct PartialChatSummary: Codable, Sendable { self.modifiedAt = modifiedAt self.origin = origin self.interactivity = interactivity - self.workingDirectory = workingDirectory + self.workingDirectories = workingDirectories + self.primaryWorkingDirectory = primaryWorkingDirectory } } @@ -1982,6 +2044,10 @@ public enum StateAction: Codable, Sendable { case sessionServerToolsChanged(SessionServerToolsChangedAction) case sessionActiveClientSet(SessionActiveClientSetAction) case sessionActiveClientRemoved(SessionActiveClientRemovedAction) + case sessionWorkingDirectorySet(SessionWorkingDirectorySetAction) + case sessionWorkingDirectoryRemoved(SessionWorkingDirectoryRemovedAction) + case chatWorkingDirectorySet(ChatWorkingDirectorySetAction) + case chatWorkingDirectoryRemoved(ChatWorkingDirectoryRemovedAction) case sessionInputNeededSet(SessionInputNeededSetAction) case sessionInputNeededRemoved(SessionInputNeededRemovedAction) case chatPendingMessageSet(ChatPendingMessageSetAction) @@ -2109,6 +2175,14 @@ public enum StateAction: Codable, Sendable { self = .sessionActiveClientSet(try SessionActiveClientSetAction(from: decoder)) case "session/activeClientRemoved": self = .sessionActiveClientRemoved(try SessionActiveClientRemovedAction(from: decoder)) + case "session/workingDirectorySet": + self = .sessionWorkingDirectorySet(try SessionWorkingDirectorySetAction(from: decoder)) + case "session/workingDirectoryRemoved": + self = .sessionWorkingDirectoryRemoved(try SessionWorkingDirectoryRemovedAction(from: decoder)) + case "chat/workingDirectorySet": + self = .chatWorkingDirectorySet(try ChatWorkingDirectorySetAction(from: decoder)) + case "chat/workingDirectoryRemoved": + self = .chatWorkingDirectoryRemoved(try ChatWorkingDirectoryRemovedAction(from: decoder)) case "session/inputNeededSet": self = .sessionInputNeededSet(try SessionInputNeededSetAction(from: decoder)) case "session/inputNeededRemoved": @@ -2244,6 +2318,10 @@ public enum StateAction: Codable, Sendable { case .sessionServerToolsChanged(let v): try v.encode(to: encoder) case .sessionActiveClientSet(let v): try v.encode(to: encoder) case .sessionActiveClientRemoved(let v): try v.encode(to: encoder) + case .sessionWorkingDirectorySet(let v): try v.encode(to: encoder) + case .sessionWorkingDirectoryRemoved(let v): try v.encode(to: encoder) + case .chatWorkingDirectorySet(let v): try v.encode(to: encoder) + case .chatWorkingDirectoryRemoved(let v): try v.encode(to: encoder) case .sessionInputNeededSet(let v): try v.encode(to: encoder) case .sessionInputNeededRemoved(let v): try v.encode(to: encoder) case .chatPendingMessageSet(let v): try v.encode(to: encoder) diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift index 5d18b93be..59f6eb603 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift @@ -354,8 +354,41 @@ public struct CreateSessionParams: Codable, Sendable { public var channel: String /// Agent provider ID public var provider: String? - /// Working directory for the session - public var workingDirectory: String? + /// The working directories the session's agent is granted tool access to. + /// A session may span multiple directories; they are equal peers except when + /// the agent advertises + /// {@link MultipleWorkingDirectoriesCapability.requiresPrimary}, in which case + /// one of them should be designated the primary via + /// {@link primaryWorkingDirectory}. + /// + /// A client MUST NOT supply more than one entry unless the agent advertises + /// {@link AgentCapabilities.multipleWorkingDirectories}; a server without that + /// capability treats only the first entry as the session's working directory + /// and ignores the rest. Dispatch `session/workingDirectorySet` / + /// `session/workingDirectoryRemoved` to change the set after the session has + /// started. + /// + /// Ignored for forked sessions — a fork inherits its working directories + /// from the source session identified by `fork`. + public var workingDirectories: [String]? + /// The primary working directory for the session's **default chat**. + /// + /// A session has no primary of its own — primary is a per-chat notion (see + /// {@link ChatState.primaryWorkingDirectory}). But `createSession` implicitly + /// creates the session's default chat, and there is no separate `createChat` + /// call to carry that chat's create-time fields. This field is therefore the + /// only place a client can designate the **default chat's** primary at birth; + /// it is copied into that chat's read-only `primaryWorkingDirectory`. For any + /// non-default chat, pass {@link CreateChatParams.primaryWorkingDirectory} + /// instead. + /// + /// When set, it MUST be one of {@link workingDirectories}. A client SHOULD + /// supply this when the agent advertises + /// {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY + /// reject creation that omits it, or fall back to the first entry of + /// `workingDirectories`. Ignored for forked sessions (a fork inherits the + /// source session's chats and their primaries). + public var primaryWorkingDirectory: String? /// Fork from an existing session. The new session is populated with content /// from the source session up to and including the specified turn's response. public var fork: SessionForkSource? @@ -384,7 +417,8 @@ public struct CreateSessionParams: Codable, Sendable { public init( channel: String, provider: String? = nil, - workingDirectory: String? = nil, + workingDirectories: [String]? = nil, + primaryWorkingDirectory: String? = nil, fork: SessionForkSource? = nil, config: [String: AnyCodable]? = nil, activeClient: SessionActiveClient? = nil, @@ -392,7 +426,8 @@ public struct CreateSessionParams: Codable, Sendable { ) { self.channel = channel self.provider = provider - self.workingDirectory = workingDirectory + self.workingDirectories = workingDirectories + self.primaryWorkingDirectory = primaryWorkingDirectory self.fork = fork self.config = config self.activeClient = activeClient @@ -435,17 +470,40 @@ public struct CreateChatParams: Codable, Sendable { public var initialMessage: Message? /// Optional source chat and turn to fork from. public var source: ChatForkSource? + /// Initial working-directory subset for this chat. Every entry MUST be + /// present in the owning session's `workingDirectories`; the server MUST + /// reject any entry that is not. When absent, the chat inherits the full + /// session set. Forked chats (`source`) inherit the source chat's + /// `workingDirectories`; this field is ignored for forked chats. + /// + /// A client MUST NOT supply this field unless the agent advertises + /// {@link AgentCapabilities.multipleWorkingDirectories}. + public var workingDirectories: [String]? + /// The chat's primary working directory — the distinguished root this chat is + /// centered on. When set, it MUST be one of the chat's effective working + /// directories ({@link workingDirectories}, or the session's set when that is + /// omitted). A client SHOULD supply this when the agent advertises + /// {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY + /// reject creation that omits it, or fall back to the first of the chat's + /// directories. Fixed at creation and reported (read-only) on + /// {@link ChatState.primaryWorkingDirectory}. Ignored for forked chats (a fork + /// inherits the source chat's primary). + public var primaryWorkingDirectory: String? public init( channel: String, chat: String, initialMessage: Message? = nil, - source: ChatForkSource? = nil + source: ChatForkSource? = nil, + workingDirectories: [String]? = nil, + primaryWorkingDirectory: String? = nil ) { self.channel = channel self.chat = chat self.initialMessage = initialMessage self.source = source + self.workingDirectories = workingDirectories + self.primaryWorkingDirectory = primaryWorkingDirectory } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Notifications.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Notifications.generated.swift index 6c0ce0602..7c24cd6a3 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Notifications.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Notifications.generated.swift @@ -182,11 +182,15 @@ public struct PartialSessionSummary: Codable, Sendable { public var activity: String? /// Server-owned project for this session public var project: ProjectInfo? - /// The default working directory URI for this session. Individual chats - /// MAY override via {@link ChatSummary.workingDirectory | their own - /// `workingDirectory`}; this field acts as the fallback for any chat that - /// does not. - public var workingDirectory: String? + /// The working directories the session's agent has tool access to, as + /// maintained by the `session/workingDirectorySet` / + /// `session/workingDirectoryRemoved` actions. Directories are **equal peers** — + /// the session has no primary. Individual chats MAY restrict to a subset via + /// {@link ChatSummary.workingDirectories | their own `workingDirectories`} and + /// designate one of their own directories as primary (see + /// {@link ChatState.primaryWorkingDirectory}); a chat that sets no subset + /// operates against this full set. + public var workingDirectories: [String]? /// Lightweight summary of this session's inline annotations channel /// (`ahp-session://annotations`). Surfaced so badge UI can render /// annotation / entry counts without subscribing. Absent when the session @@ -215,7 +219,7 @@ public struct PartialSessionSummary: Codable, Sendable { case status case activity case project - case workingDirectory + case workingDirectories case annotations case resource case createdAt @@ -230,7 +234,7 @@ public struct PartialSessionSummary: Codable, Sendable { status: SessionStatus? = nil, activity: String? = nil, project: ProjectInfo? = nil, - workingDirectory: String? = nil, + workingDirectories: [String]? = nil, annotations: AnnotationsSummary? = nil, resource: String? = nil, createdAt: String? = nil, @@ -243,7 +247,7 @@ public struct PartialSessionSummary: Codable, Sendable { self.status = status self.activity = activity self.project = project - self.workingDirectory = workingDirectory + self.workingDirectories = workingDirectories self.annotations = annotations self.resource = resource self.createdAt = createdAt diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index b1f7d1689..3c9dacc61 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -632,11 +632,22 @@ public struct AgentCapabilities: Codable, Sendable { /// session starts with. An empty object `{}` advertises multi-chat without /// forking; set {@link MultipleChatsCapability.fork} to also allow forking. public var multipleChats: MultipleChatsCapability? + /// The session's agent can be granted tool access to more than one working + /// directory. The directories are treated as equal peers except where the + /// agent advertises {@link MultipleWorkingDirectoriesCapability.requiresPrimary} + /// (some backends need one directory designated as a primary root). + /// + /// When absent, clients MUST NOT mutate a session's or chat's working-directory + /// set and MUST NOT set more than one entry in + /// {@link CreateSessionParams.workingDirectories}. + public var multipleWorkingDirectories: MultipleWorkingDirectoriesCapability? public init( - multipleChats: MultipleChatsCapability? = nil + multipleChats: MultipleChatsCapability? = nil, + multipleWorkingDirectories: MultipleWorkingDirectoriesCapability? = nil ) { self.multipleChats = multipleChats + self.multipleWorkingDirectories = multipleWorkingDirectories } } @@ -653,6 +664,28 @@ public struct MultipleChatsCapability: Codable, Sendable { } } +public struct MultipleWorkingDirectoriesCapability: Codable, Sendable { + /// The agent requires each chat to designate one of its working directories as + /// the **primary** — a distinguished root the chat is centered on (e.g. the + /// agent's process root for that chat, the default location for relative + /// paths). Primary is a **per-chat** notion, fixed at chat creation. When + /// `true`, a client SHOULD supply {@link CreateChatParams.primaryWorkingDirectory} + /// (and {@link CreateSessionParams.primaryWorkingDirectory}, which seeds the + /// session's default chat); a host MAY reject creation that omits it, or fall + /// back to the first entry of the chat's working directories. The chosen + /// primary is reported (read-only) on {@link ChatState.primaryWorkingDirectory}. + /// + /// When absent or `false`, the agent has no primary — all directories are + /// equal peers and clients need not designate one. + public var requiresPrimary: Bool? + + public init( + requiresPrimary: Bool? = nil + ) { + self.requiresPrimary = requiresPrimary + } +} + public struct SessionModelInfo: Codable, Sendable { /// Model identifier public var id: String @@ -869,14 +902,31 @@ public struct ChatState: Codable, Sendable { /// Absence defaults to {@link ChatInteractivity.Full} for backward /// compatibility. public var interactivity: ChatInteractivity? - /// Optional per-chat working directory. + /// The subset of the session's + /// {@link SessionState.workingDirectories | `workingDirectories`} that this + /// chat's agent has tool access to. Every entry MUST be present in the owning + /// session's `workingDirectories`; servers MUST reject a + /// `chat/workingDirectorySet` action that violates this constraint. + /// + /// When absent, the chat inherits the full session set. When present but empty + /// (not recommended), the chat has no working-directory tool access at all. /// - /// If absent, the chat inherits - /// {@link SessionState.workingDirectory | the session's working directory}. - /// Hosts MAY override this for individual chats — for example, to give a - /// subordinate chat its own git worktree so multiple chats in a session can - /// make independent edits that the orchestrator later merges back. - public var workingDirectory: String? + /// Dispatch `chat/workingDirectorySet` / `chat/workingDirectoryRemoved` to + /// update the subset on a running chat. + public var workingDirectories: [String]? + /// The chat's primary working directory — the distinguished root this chat is + /// centered on (e.g. the agent's process root for this chat, the default + /// location for relative paths). MUST be one of this chat's effective working + /// directories ({@link workingDirectories}, or the session's set when that is + /// absent). Present when the agent advertises + /// {@link MultipleWorkingDirectoriesCapability.requiresPrimary}. + /// + /// **Read-only and fixed at creation.** It is set from + /// {@link CreateChatParams.primaryWorkingDirectory} (or, for the session's + /// default chat, {@link CreateSessionParams.primaryWorkingDirectory}) and does + /// not change over the chat's lifetime — there is no action to mutate it, and + /// it does not participate in `session/chatUpdated`. + public var primaryWorkingDirectory: String? /// Completed turns public var turns: [Turn] /// Cursor for loading older completed turns into this chat state. @@ -915,7 +965,8 @@ public struct ChatState: Codable, Sendable { case modifiedAt case origin case interactivity - case workingDirectory + case workingDirectories + case primaryWorkingDirectory case turns case turnsNextCursor case activeTurn @@ -933,7 +984,8 @@ public struct ChatState: Codable, Sendable { modifiedAt: String, origin: ChatOrigin? = nil, interactivity: ChatInteractivity? = nil, - workingDirectory: String? = nil, + workingDirectories: [String]? = nil, + primaryWorkingDirectory: String? = nil, turns: [Turn], turnsNextCursor: String? = nil, activeTurn: ActiveTurn? = nil, @@ -949,7 +1001,8 @@ public struct ChatState: Codable, Sendable { self.modifiedAt = modifiedAt self.origin = origin self.interactivity = interactivity - self.workingDirectory = workingDirectory + self.workingDirectories = workingDirectories + self.primaryWorkingDirectory = primaryWorkingDirectory self.turns = turns self.turnsNextCursor = turnsNextCursor self.activeTurn = activeTurn @@ -979,12 +1032,12 @@ public struct ChatSummary: Codable, Sendable { /// Absence defaults to {@link ChatInteractivity.Full} for backward /// compatibility. public var interactivity: ChatInteractivity? - /// Optional per-chat working directory. - /// - /// If absent, the chat inherits - /// {@link SessionSummary.workingDirectory | the session's working directory}. - /// See {@link ChatState.workingDirectory} for usage notes. - public var workingDirectory: String? + /// The subset of the session's working directories this chat uses. + /// See {@link ChatState.workingDirectories} for the full semantics. + public var workingDirectories: [String]? + /// The chat's primary working directory. + /// See {@link ChatState.primaryWorkingDirectory} for the full semantics. + public var primaryWorkingDirectory: String? public init( resource: String, @@ -994,7 +1047,8 @@ public struct ChatSummary: Codable, Sendable { modifiedAt: String, origin: ChatOrigin? = nil, interactivity: ChatInteractivity? = nil, - workingDirectory: String? = nil + workingDirectories: [String]? = nil, + primaryWorkingDirectory: String? = nil ) { self.resource = resource self.title = title @@ -1003,7 +1057,8 @@ public struct ChatSummary: Codable, Sendable { self.modifiedAt = modifiedAt self.origin = origin self.interactivity = interactivity - self.workingDirectory = workingDirectory + self.workingDirectories = workingDirectories + self.primaryWorkingDirectory = primaryWorkingDirectory } } @@ -1018,11 +1073,15 @@ public struct SessionState: Codable, Sendable { public var activity: String? /// Server-owned project for this session public var project: ProjectInfo? - /// The default working directory URI for this session. Individual chats - /// MAY override via {@link ChatSummary.workingDirectory | their own - /// `workingDirectory`}; this field acts as the fallback for any chat that - /// does not. - public var workingDirectory: String? + /// The working directories the session's agent has tool access to, as + /// maintained by the `session/workingDirectorySet` / + /// `session/workingDirectoryRemoved` actions. Directories are **equal peers** — + /// the session has no primary. Individual chats MAY restrict to a subset via + /// {@link ChatSummary.workingDirectories | their own `workingDirectories`} and + /// designate one of their own directories as primary (see + /// {@link ChatState.primaryWorkingDirectory}); a chat that sets no subset + /// operates against this full set. + public var workingDirectories: [String]? /// Lightweight summary of this session's inline annotations channel /// (`ahp-session://annotations`). Surfaced so badge UI can render /// annotation / entry counts without subscribing. Absent when the session @@ -1098,7 +1157,7 @@ public struct SessionState: Codable, Sendable { /// /// Clients MAY look for well-known keys here to provide enhanced UI. /// For example, a `git` key may provide extra git metadata about the session's - /// workingDirectory. + /// working directories. public var meta: [String: AnyCodable]? enum CodingKeys: String, CodingKey { @@ -1107,7 +1166,7 @@ public struct SessionState: Codable, Sendable { case status case activity case project - case workingDirectory + case workingDirectories case annotations case lifecycle case creationError @@ -1128,7 +1187,7 @@ public struct SessionState: Codable, Sendable { status: SessionStatus, activity: String? = nil, project: ProjectInfo? = nil, - workingDirectory: String? = nil, + workingDirectories: [String]? = nil, annotations: AnnotationsSummary? = nil, lifecycle: SessionLifecycle, creationError: ErrorInfo? = nil, @@ -1147,7 +1206,7 @@ public struct SessionState: Codable, Sendable { self.status = status self.activity = activity self.project = project - self.workingDirectory = workingDirectory + self.workingDirectories = workingDirectories self.annotations = annotations self.lifecycle = lifecycle self.creationError = creationError @@ -1333,11 +1392,15 @@ public struct SessionSummary: Codable, Sendable { public var activity: String? /// Server-owned project for this session public var project: ProjectInfo? - /// The default working directory URI for this session. Individual chats - /// MAY override via {@link ChatSummary.workingDirectory | their own - /// `workingDirectory`}; this field acts as the fallback for any chat that - /// does not. - public var workingDirectory: String? + /// The working directories the session's agent has tool access to, as + /// maintained by the `session/workingDirectorySet` / + /// `session/workingDirectoryRemoved` actions. Directories are **equal peers** — + /// the session has no primary. Individual chats MAY restrict to a subset via + /// {@link ChatSummary.workingDirectories | their own `workingDirectories`} and + /// designate one of their own directories as primary (see + /// {@link ChatState.primaryWorkingDirectory}); a chat that sets no subset + /// operates against this full set. + public var workingDirectories: [String]? /// Lightweight summary of this session's inline annotations channel /// (`ahp-session://annotations`). Surfaced so badge UI can render /// annotation / entry counts without subscribing. Absent when the session @@ -1366,7 +1429,7 @@ public struct SessionSummary: Codable, Sendable { case status case activity case project - case workingDirectory + case workingDirectories case annotations case resource case createdAt @@ -1381,7 +1444,7 @@ public struct SessionSummary: Codable, Sendable { status: SessionStatus, activity: String? = nil, project: ProjectInfo? = nil, - workingDirectory: String? = nil, + workingDirectories: [String]? = nil, annotations: AnnotationsSummary? = nil, resource: String, createdAt: String, @@ -1394,7 +1457,7 @@ public struct SessionSummary: Codable, Sendable { self.status = status self.activity = activity self.project = project - self.workingDirectory = workingDirectory + self.workingDirectories = workingDirectories self.annotations = annotations self.resource = resource self.createdAt = createdAt diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift index 902600cd6..e925c5866 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift @@ -3,7 +3,7 @@ import Foundation /// Current protocol version (SemVer `MAJOR.MINOR.PATCH`). -public let PROTOCOL_VERSION: String = "0.6.1" +public let PROTOCOL_VERSION: String = "0.7.0" /// Every protocol version this package is willing to negotiate, /// ordered most-preferred-first. The first entry equals @@ -13,7 +13,7 @@ public let PROTOCOL_VERSION: String = "0.6.1" /// `InitializeParams` so the same client binary can fall back to older /// protocol versions if the host doesn't accept the newest one. public let SUPPORTED_PROTOCOL_VERSIONS: [String] = [ - "0.6.1", + "0.7.0", "0.6.0", "0.5.2", "0.5.1", diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift index 617ed8e1b..dc7027fcd 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift @@ -153,6 +153,18 @@ public func chatReducer(state: ChatState, action: StateAction) -> ChatState { next.activity = a.activity return next + case .chatWorkingDirectorySet(let a): + if (state.workingDirectories ?? []).contains(a.directory) { return state } + var next = state + next.workingDirectories = (next.workingDirectories ?? []) + [a.directory] + return next + + case .chatWorkingDirectoryRemoved(let a): + guard let idx = state.workingDirectories?.firstIndex(of: a.directory) else { return state } + var next = state + next.workingDirectories?.remove(at: idx) + return next + // ── Tool Call State Machine ─────────────────────────────────────────── case .chatToolCallStart(let a): @@ -723,6 +735,18 @@ public func sessionReducer(state: SessionState, action: StateAction) -> SessionS next.activeClients.remove(at: idx) return next + case .sessionWorkingDirectorySet(let a): + if (state.workingDirectories ?? []).contains(a.directory) { return state } + var next = state + next.workingDirectories = (next.workingDirectories ?? []) + [a.directory] + return next + + case .sessionWorkingDirectoryRemoved(let a): + guard let idx = state.workingDirectories?.firstIndex(of: a.directory) else { return state } + var next = state + next.workingDirectories?.remove(at: idx) + return next + case .sessionInputNeededSet(let a): guard let id = sessionInputRequestID(a.request) else { return state } var next = state @@ -905,7 +929,7 @@ private func mergeChatSummaryChanges(_ summary: inout ChatSummary, changes: Part if let activity = changes.activity { summary.activity = activity } if let modifiedAt = changes.modifiedAt { summary.modifiedAt = modifiedAt } if let origin = changes.origin { summary.origin = origin } - if let workingDirectory = changes.workingDirectory { summary.workingDirectory = workingDirectory } + if let workingDirectories = changes.workingDirectories { summary.workingDirectories = workingDirectories } } private func chatSummaryStatus(_ state: ChatState, terminalStatus: SessionStatus? = nil) -> SessionStatus { diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/Hosts/HostRuntime.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/Hosts/HostRuntime.swift index a9bb5c82c..6bb1333fc 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/Hosts/HostRuntime.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/Hosts/HostRuntime.swift @@ -829,6 +829,6 @@ private func applySummaryChanges( if let v = changes.modifiedAt { existing.modifiedAt = v } if let v = changes.project { existing.project = v } if let v = changes.annotations { existing.annotations = v } - if let v = changes.workingDirectory { existing.workingDirectory = v } + if let v = changes.workingDirectories { existing.workingDirectories = v } } diff --git a/clients/swift/release-metadata.json b/clients/swift/release-metadata.json index f83f6cee9..7aa7042b2 100644 --- a/clients/swift/release-metadata.json +++ b/clients/swift/release-metadata.json @@ -2,7 +2,7 @@ "client": "swift", "packageVersion": "0.6.0", "supportedProtocolVersions": [ - "0.6.1", + "0.7.0", "0.6.0", "0.5.2", "0.5.1" diff --git a/clients/typescript/release-metadata.json b/clients/typescript/release-metadata.json index f53bcb54f..37c6ac0b8 100644 --- a/clients/typescript/release-metadata.json +++ b/clients/typescript/release-metadata.json @@ -2,7 +2,7 @@ "client": "typescript", "packageVersion": "0.6.0", "supportedProtocolVersions": [ - "0.6.1", + "0.7.0", "0.6.0", "0.5.2", "0.5.1" diff --git a/clients/typescript/src/client/hosts/runtime.ts b/clients/typescript/src/client/hosts/runtime.ts index f5e2c0150..09e3ae830 100644 --- a/clients/typescript/src/client/hosts/runtime.ts +++ b/clients/typescript/src/client/hosts/runtime.ts @@ -879,7 +879,7 @@ function applySummaryChange( if (changes.activity !== undefined) merged.activity = changes.activity; if (changes.modifiedAt !== undefined) merged.modifiedAt = changes.modifiedAt; if (changes.project !== undefined) merged.project = changes.project; - if (changes.workingDirectory !== undefined) merged.workingDirectory = changes.workingDirectory; + if (changes.workingDirectories !== undefined) merged.workingDirectories = changes.workingDirectories; if (changes._meta !== undefined) merged._meta = changes._meta; cache.set(params.session, merged); } diff --git a/docs/.changes/20260716-multiroot-sessions.json b/docs/.changes/20260716-multiroot-sessions.json new file mode 100644 index 000000000..b52dbaac9 --- /dev/null +++ b/docs/.changes/20260716-multiroot-sessions.json @@ -0,0 +1,5 @@ +{ + "type": "added", + "message": "Multiroot session support: `AgentCapabilities.multipleWorkingDirectories` capability (with `requiresPrimary`), `CreateSessionParams.workingDirectories` and `SessionMetadata.workingDirectories` (an equal-peer set, mirrored onto `SessionState`/`SessionSummary`), `CreateChatParams.workingDirectories` and `ChatState`/`ChatSummary.workingDirectories` (a subset of the session's set), a per-chat read-only `ChatState`/`ChatSummary.primaryWorkingDirectory` (fixed at chat creation; set via `CreateChatParams.primaryWorkingDirectory`, or `CreateSessionParams.primaryWorkingDirectory` for the default chat), and the client-dispatchable `session/workingDirectorySet` / `session/workingDirectoryRemoved` and `chat/workingDirectorySet` / `chat/workingDirectoryRemoved` actions for mutating those sets.", + "issues": [337] +} diff --git a/docs/guide/changesets.md b/docs/guide/changesets.md index 9d3800730..f2e8803f5 100644 --- a/docs/guide/changesets.md +++ b/docs/guide/changesets.md @@ -55,6 +55,20 @@ unknown variables. | `{turnId}` | Per-turn slice. Expand with a `Turn.id` from one of the session's chats. | | `{originalTurnId}` and `{modifiedTurnId}` | Diff between two turns. Both must be present. | +### Multiroot Sessions + +A changeset is not scoped to a single working directory — a per-turn or +session-wide changeset naturally spans every directory the agent touched. A +client that wants to present changes grouped by directory does so itself, by +matching each file's URI against the session's +[`workingDirectories`](/guide/state-model#multiroot-sessions) (a list the +client already has); a client that does not care simply renders one tree. + +A host MAY *also* advertise dedicated per-directory changesets — one catalogue +entry per working directory — for clients that prefer server-scoped views. This +needs no extra field: the `changesets` catalogue is already a list, so a host +lists one entry per directory alongside the spanning ones. + ### Changeset State Each concrete (expanded) changeset URI is its own subscribable resource. diff --git a/docs/guide/state-model.md b/docs/guide/state-model.md index 1fcc2275f..8b4753486 100644 --- a/docs/guide/state-model.md +++ b/docs/guide/state-model.md @@ -73,7 +73,7 @@ SessionState { status: number // SessionStatus bitset activity?: string project?: ProjectInfo - workingDirectory?: URI + workingDirectories?: URI[] // equal-peer working directories annotations?: AnnotationsSummary lifecycle: 'creating' | 'ready' | 'creationFailed' @@ -107,7 +107,7 @@ SessionSummary { createdAt: string // ISO 8601, e.g. "2025-03-10T18:42:03.123Z" modifiedAt: string // ISO 8601 project?: ProjectInfo - workingDirectory?: URI + workingDirectories?: URI[] // equal-peer working directories annotations?: AnnotationsSummary changes?: ChangesSummary } @@ -150,7 +150,8 @@ ChatState { activity?: string modifiedAt: string origin?: ChatOrigin // how the chat came to exist (user / fork / tool) - workingDirectory?: URI + workingDirectories?: URI[] // subset of session's workingDirectories + primaryWorkingDirectory?: URI // this chat's primary, read-only (set at creation, when requiresPrimary) turns: Turn[] // completed turns turnsNextCursor?: string // page older turns via fetchTurns @@ -562,6 +563,120 @@ createSession({ The forked session is an independent copy — subsequent changes to either session do not affect the other. The server broadcasts `root/sessionAdded` for the new session as usual. +## Multiroot Sessions + +A session can be granted tool access to more than one working directory when the +agent advertises the `multipleWorkingDirectories` capability. At the session +level the directories are always **equal peers** — a session has no primary. A +**primary** is a per-chat notion (see [below](#per-chat-working-directory-subsets)); +the session simply owns the set every chat draws from. + +### Creating a multiroot session + +Pass `workingDirectories` (plural) in `createSession`. When the agent +`requiresPrimary`, also pass `primaryWorkingDirectory` — it seeds the primary of +the session's **default chat** (the session itself stores no primary): + +```typescript +createSession({ + channel: 'ahp-session:/', + provider: 'copilot', + workingDirectories: [ + 'file:///workspace/frontend', + 'file:///workspace/backend', + ], + primaryWorkingDirectory: 'file:///workspace/frontend', // seeds default chat, when requiresPrimary +}); +``` + +A client MUST NOT pass more than one entry unless the agent advertises +`multipleWorkingDirectories`. Servers without that capability treat only the +first entry as the session's working directory and ignore the rest. + +When the agent advertises `multipleWorkingDirectories.requiresPrimary`, a client +SHOULD supply `primaryWorkingDirectory` (which MUST be one of +`workingDirectories`); a host MAY reject a creation that omits it, or fall back +to the first entry. It becomes the default chat's read-only +`ChatState.primaryWorkingDirectory`. + +> **Why is `primaryWorkingDirectory` on `createSession` if the session has no +> primary?** Because `createSession` implicitly creates the session's **default +> chat**, and there is no separate `createChat` call to carry that chat's +> create-time fields. This field is the only place to designate the default +> chat's primary at birth. For any additional chat, pass its primary to +> `createChat` instead. + +Forked sessions ignore `workingDirectories` / `primaryWorkingDirectory` — they +inherit the working directories (and per-chat primaries) of the source session. + +### Managing directories after creation + +The directory set is state (`SessionState.workingDirectories`), so clients +mutate it by **dispatching actions**, not by calling commands: + +| Action | Effect | +| --- | --- | +| `session/workingDirectorySet` | Adds `directory` to the set (creating it if absent). A no-op when the directory is already present. | +| `session/workingDirectoryRemoved` | Removes `directory` from the set. A no-op when it is not present. There is no atomic backend "remove one" primitive — the host reconfigures its agent to the reduced set. A host MAY decline to apply the removal (e.g. a directory still designated as some chat's primary), leaving the set unchanged. | + +Both are `@clientDispatchable`. The resulting set is observed on +`SessionState.workingDirectories` like any other state — there is no separate +result payload. + +Before dispatching either action, a client MUST verify that the agent advertises +`multipleWorkingDirectories`. + +### Per-chat working-directory subsets + +Each chat within a multiroot session may further restrict the directories it +uses to a **subset** of the session's `workingDirectories`. This lets +different chats in the same session each focus on a different part of the +workspace — for example, a frontend chat and a backend chat. + +The effective set for a chat is recorded in `ChatState.workingDirectories` / +`ChatSummary.workingDirectories`. When absent the chat inherits the full +session set. + +A chat also designates its own **primary** working directory — +`ChatState.primaryWorkingDirectory` (mirrored on `ChatSummary`). It is +**read-only and fixed at chat creation**: there is no action to change it, and +it does not participate in `session/chatUpdated`. Present only when the agent +`requiresPrimary`. Each chat can pick a different primary from its own effective +directories. + +#### Setting at create time + +Pass `workingDirectories` to `createChat`. Every entry must already exist in +the session's `workingDirectories`. When the agent `requiresPrimary`, also pass +`primaryWorkingDirectory` (one of the chat's effective directories): + +```typescript +createChat({ + channel: 'ahp-session:/', + chat: 'ahp-chat:/', + workingDirectories: ['file:///workspace/frontend'], // subset + primaryWorkingDirectory: 'file:///workspace/frontend', // this chat's primary, when requiresPrimary +}); +``` + +Forked chats (those with a `source`) inherit the source chat's +`workingDirectories` and primary; both fields are ignored for them. + +#### Managing the subset after creation + +Two `@clientDispatchable` actions mutate a running chat's working-directory +subset: + +| Action | Effect | +| --- | --- | +| `chat/workingDirectorySet` | Adds `directory` to the chat's subset. It MUST already be in the session's `workingDirectories`; a host MUST reject a directory that is not. A no-op when already in the chat's subset. | +| `chat/workingDirectoryRemoved` | Removes `directory` from the chat's subset (idempotent). Only affects the chat — the directory stays in the session's set. | + +The subset is observed on `ChatState.workingDirectories`. + +A client MUST NOT dispatch these actions unless the agent advertises +`multipleWorkingDirectories`. + ## Next Steps - [Actions](/guide/actions) — How state is mutated. diff --git a/docs/proposals/multiroot-sessions.md b/docs/proposals/multiroot-sessions.md new file mode 100644 index 000000000..0887b60d6 --- /dev/null +++ b/docs/proposals/multiroot-sessions.md @@ -0,0 +1,429 @@ +# Multiroot Sessions — Feature Overview + +> A conceptual walkthrough of the multiroot-sessions feature for presentations +> and design discussion, plus a concise map of the concrete protocol surface so +> reviewers can connect the framing to the actual `types/` changes. Like the +> [multi-chat overview](./multi-chat.md), it explains **what** the capability is +> and **why** it exists before it gets to the wire shape. + +--- + +## 1. The problem + +An agent session today is scoped to **a single working directory**. The session +`workingDirectory` is the one root the agent has tool access to — the folder it +reads, edits, runs commands in, and diffs. + +That model is increasingly at odds with real tasks. Work routinely spans **more +than one directory at a time**: + +- A change to a service *and* the shared library it depends on, in sibling + repositories. +- A monorepo task that touches two packages that a user keeps as separate + checkouts. +- A migration that edits a source repo and its generated-client repo together. +- A VS Code **multi-root workspace** (`.code-workspace`) whose folders are, to + the user, one project. + +When a session can only ever see one directory, the user is forced to either +flatten everything under one root that doesn't match how they actually work, or +run **disconnected sessions** per folder that lose their shared context (the same +task, the same conversation, the same configuration). + +**The feature in one sentence:** let a single session grant its agent tool +access to *multiple working directories* — equal peers, no privileged +"primary" — so that cross-directory work can be represented and driven as one +coherent whole. + +--- + +## 2. The mental model + +Three roles, nested: + +- **A session owns a *set* of working directories.** They are **equal peers** — + there is no "primary" and no "additional." The session is the boundary of what + the agent may touch: every directory in the set is fair game, nothing outside + it is. + +- **A chat operates in a *subset* of the session's directories.** A single + thread of work usually focuses on part of the session. A chat may pin itself to + one directory (or a few); when it pins nothing, it sees the session's whole + set. + +- **Changes are not locked to a directory.** A changeset (e.g. "last turn") may + span several working directories. Clients that want a per-directory view group + a changeset's files themselves against the session's directory list; a host + MAY additionally expose dedicated per-directory changesets as extra catalogue + entries. + +> One session, many equal directories. Each chat narrows to a subset. Changes +> can span directories; grouping is optional. + +```mermaid +flowchart TB + S["Session
workingDirectories: [repo-a, repo-b, repo-c]
equal peers — no primary"] + S --> CA["Chat A
subset: [repo-a, repo-b]"] + S --> CB["Chat B
subset: [repo-c]"] + S --> CC["Chat C
no subset → whole set"] +``` + +A helpful analogy: the session is a **VS Code multi-root workspace**, its +directories are the **workspace folders**, and a chat is a **task** that may care +about only some of those folders. + +--- + +## 3. Where this feature lives in the stack + +As with multi-chat, this feature is a **window** onto what the harness does — not +the mechanism itself. + +``` + ┌─────────────┐ ┌──────────────────────────────┐ + │ UI client │ ◄── feature layer ──► │ Agent harness │ + │ (the app a │ directory set, per- │ ── grants the agent tool │ + │ user sees) │ chat subset, per-dir │ access to N directories │ + └─────────────┘ changesets │ ── roots the process / │ + │ applies path grants │ + └──────────────────────────────┘ +``` + +- **The harness layer** is where directory access actually takes effect: rooting + the agent process, applying filesystem path grants, isolating worktrees. How a + backend enforces "the agent may touch these three folders" is its own business. + +- **The feature/interoperability layer** (where this feature lives) lets a UI + *declare and observe* that set — pick the directories at session creation, + add/remove them later, narrow a chat to a subset, and render changes grouped by + directory. + +**The feature is about representation and control, not access enforcement.** It +gives clients a vocabulary for "this session works across these folders"; the +harness decides how that is realized. + +--- + +## 4. What the feature gives you + +At the feature level, multiroot sessions introduce a small, additive set of +capabilities: + +1. **A capability gate.** An agent advertises whether it supports more than one + working directory. Clients check it before offering any multiroot affordance; + agents that don't support it behave exactly as today. + +2. **A session directory set.** A session is created with a *list* of working + directories, all equal peers. A single-directory session is just the list of + length one. + +3. **Add / remove after start.** Directories can be granted or revoked while the + session runs. Removal is modelled as "reconfigure to this reduced set" — there + is no fragile single-remove primitive underneath. + +4. **A per-chat subset.** Each chat may narrow to a subset of the session's + directories (every entry must be one of the session's). A chat that narrows + nothing operates against the whole set. + +5. **Optional per-directory change views.** Changesets are cross-cutting (a + per-turn changeset spans every directory the agent touched). A client that + wants a per-directory view groups a changeset's files itself against the + session's directory list; a host MAY also expose dedicated per-directory + changesets as extra catalogue entries. Nothing forces a one-directory scope. + +```mermaid +flowchart LR + Cap["capability:
multipleWorkingDirectories?"] --> Create["createSession
workingDirectories[]"] + Create --> Add["session/workingDirectorySet /
Removed actions"] + Create --> Chat["chat subset
workingDirectories ⊆ session"] + Create --> CS["changesets
(optional per-dir entries)"] +``` + +--- + +## 5. Worked example: a cross-repo change + +A user asks an agent to update an API and the client library that consumes it, +kept as two separate checkouts. + +| What the user / harness does | How the feature represents it | +| --- | --- | +| User starts a task over `api/` and `client/` | A session with `workingDirectories: [api, client]`. | +| Agent edits both repos | The agent has tool access to both directories, equal peers. | +| User opens a focused thread on just the client | A chat pinned to `workingDirectories: [client]`. | +| Agent later needs the shared `protos/` repo too | dispatch `session/workingDirectorySet(protos)` → set becomes `[api, client, protos]`. | +| User reviews the diff | The session's changeset lists every changed file across the dirs; the client groups them by directory for display if it wants. | +| The `protos/` work turns out unnecessary | dispatch `session/workingDirectoryRemoved(protos)` → set reconfigures back to `[api, client]`. | + +The session stays one coherent conversation and one shared configuration +throughout — no juggling three disconnected sessions. + +--- + +## 6. What this feature deliberately is *not* + +- **It is not a permissions / sandboxing model.** The directory set says *which + folders the session works across*, not a fine-grained ACL of what the agent may + read vs. write. Enforcement and least-privilege policy stay a harness concern. + +- **It is not per-file or per-glob scoping.** The unit is a working directory (a + root), not arbitrary path patterns within it. + +- **No mandatory primary; when needed, it's per-chat.** All directories are + peers by default and the **session has no primary at all**. A backend that + *needs* one distinguished root advertises `requiresPrimary`; the client then + designates it explicitly per **chat** via `primaryWorkingDirectory` (on + `createChat`, or `createSession` to seed the default chat). It is read-only + and fixed at chat creation — never inferred from array position, never mutated + after birth. + +- **It is not multi-root *chats as sub-sessions*.** A chat narrowing to a subset + is still one thread under one session's trust and identity. Independent agents + with their own lifecycle remain the separate, future sub-session concept. + +These omissions keep the feature small and composable. Richer path policy, +per-tool scoping, or workspace-level config are natural *future* axes that can +layer on without breaking this shape. + +--- + +## 7. Why this shape + +- **Equal peers, no primary.** The user's mental model ("these folders are my + project") has no privileged root. Encoding a primary would create a confusing + "what's the relationship between primary and secondary?" question with no good + answer — so the model refuses it. + +- **Session owns, chat subsets.** The broadest scope lives where it is shared + (the session); a chat only ever *narrows*, never widens. This keeps the + invariant simple: a chat's directories are always ⊆ the session's. + +- **Changesets stay cross-cutting.** A changeset isn't tied to one directory — + a per-turn diff can span several — so no directory field is forced onto it. + Per-directory presentation is optional: the client groups a changeset's files + against the known directory list, or the host advertises extra per-directory + catalogue entries. Both reuse existing shapes; neither needs arrays-of-arrays. + +- **Additive and capability-gated.** Every new field is optional; the commands + are gated behind a capability plus a version handshake. A single-directory + harness is untouched; a multiroot harness simply lights up more directories. + +```mermaid +flowchart LR + A["Single-dir session
(workingDirectories: [one])"] -->|same wire shape| B["Multiroot session
(workingDirectories: [N])"] + A -. looks like today .-> A + B -. folder set + per-dir diffs .-> B +``` + +--- + +## 8. Protocol surface (for reviewers) + +The concrete, additive changes that back the framing above — a reviewer's map of +the `types/` surface. Full prose detail lives in the +[State Model](/guide/state-model#multiroot-sessions) and +[Changesets](/guide/changesets) guides. Target spec version: **0.7.0**. + +Everything here is **additive and optional** — no field is required, no existing +field changes type, and old clients that ignore the new surface behave exactly +as today. + +### 8.1 Change table + +| Symbol | File | Kind | +| --- | --- | --- | +| `AgentCapabilities.multipleWorkingDirectories?` | `channels-root/state.ts` | added (capability) | +| `MultipleWorkingDirectoriesCapability` (`requiresPrimary?`) | `channels-root/state.ts` | added (type) | +| `CreateSessionParams.workingDirectories?` / `primaryWorkingDirectory?` | `channels-session/commands.ts` | added | +| `SessionMetadata.workingDirectories?` (→ `SessionState`, `SessionSummary`) | `channels-session/state.ts` | added | +| `session/workingDirectorySet` / `session/workingDirectoryRemoved` actions | `channels-session/actions.ts` | added (action) | +| `ChatState.workingDirectories?` / `primaryWorkingDirectory?` (& `ChatSummary`) | `channels-chat/state.ts` | added | +| `CreateChatParams.workingDirectories?` / `primaryWorkingDirectory?` | `channels-chat/commands.ts` | added | +| `chat/workingDirectorySet` / `chat/workingDirectoryRemoved` actions | `channels-chat/actions.ts` | added (action) | +| 4 `ActionType` entries + `ACTION_INTRODUCED_IN` (`0.7.0`) | `common/actions.ts`, `version/registry.ts` | added | + +> **Revised after review.** The directory-mutation surface started as four +> **commands** (`add/removeWorkspaceFolder`, `add/removeChatWorkspaceFolder` +> with request/result types). Per review, it is now four **state actions** +> following the keyed-collection convention — the set lives in state, so clients +> mutate it by dispatching actions and observe the result on +> `workingDirectories`. The deprecated singular `workingDirectory` fields were +> **hard-removed** (a breaking change → 0.7.0), not kept as a shorthand. An +> earlier revision also added a `Changeset.workingDirectory` field to group +> changes per directory; that was **dropped** — a changeset can span +> directories (e.g. a per-turn diff), so a single-directory scalar is the wrong +> model. Per-directory presentation is instead handled by optional client-side +> grouping or extra per-directory catalogue entries (see §6/§7). + +### 8.2 Type signatures + +```ts +// ── Capability — channels-root/state.ts ────────────────────────────────── +interface AgentCapabilities { + // …existing… + /** Presence ({}) = the agent supports >1 working directory per session. */ + multipleWorkingDirectories?: MultipleWorkingDirectoriesCapability; +} +interface MultipleWorkingDirectoriesCapability { + /** The agent needs one directory designated as its primary root. */ + requiresPrimary?: boolean; +} + +// ── Session create — channels-session/commands.ts ──────────────────────── +interface CreateSessionParams extends BaseParams { + // …existing… + /** The session's equal-peer working directories. */ + workingDirectories?: URI[]; + /** Seeds the default chat's primary (⊆ workingDirectories); when requiresPrimary. */ + primaryWorkingDirectory?: URI; +} + +// ── Session state — channels-session/state.ts (→ SessionState/SessionSummary) +interface SessionMetadata { + // …existing… + workingDirectories?: URI[]; // equal peers; the session has NO primary +} + +// ── Session runtime mutation — channels-session/actions.ts ──────────────── +// channel = session URI. Gated by multipleWorkingDirectories. @clientDispatchable. +interface SessionWorkingDirectorySetAction { + type: ActionType.SessionWorkingDirectorySet; // 'session/workingDirectorySet' + directory: URI; // appended; no-op if present +} +interface SessionWorkingDirectoryRemovedAction { + type: ActionType.SessionWorkingDirectoryRemoved; // 'session/workingDirectoryRemoved' + directory: URI; // removed; no-op if absent +} + +// ── Chat — channels-chat/state.ts, commands.ts & actions.ts ────────────── +interface ChatState /* and ChatSummary */ { + // …existing… + /** The chat's subset — every entry MUST be one of the session's dirs. */ + workingDirectories?: URI[]; + /** Read-only, fixed at creation; no action, not in chatUpdated. */ + primaryWorkingDirectory?: URI; +} +interface CreateChatParams extends BaseParams { + // …existing… + workingDirectories?: URI[]; // subset ⊆ session; absent → whole session set + primaryWorkingDirectory?: URI; // ⊆ chat's dirs; when requiresPrimary +} +// channel = chat URI. Gated by multipleWorkingDirectories. @clientDispatchable. +interface ChatWorkingDirectorySetAction { + type: ActionType.ChatWorkingDirectorySet; // 'chat/workingDirectorySet' + directory: URI; // MUST be in the session set +} +interface ChatWorkingDirectoryRemovedAction { + type: ActionType.ChatWorkingDirectoryRemoved; // 'chat/workingDirectoryRemoved' + directory: URI; +} + +// ── Changes — channels-changeset/state.ts ──────────────────────────────── +// No changes. A changeset can span working directories, so it carries no +// directory field. Per-directory views are optional: clients group a +// changeset's files against the session's workingDirectories, or a host +// advertises extra per-directory catalogue entries. +``` + +### 8.3 Versioning & gating + +- `PROTOCOL_VERSION` is **`0.7.0`** — a MINOR bump because the feature is + breaking (it removes the singular `workingDirectory` fields that shipped in the + released `0.6.0`), and pre-1.0 breaking changes land in a MINOR. + `SUPPORTED_PROTOCOL_VERSIONS` = `[0.7.0, 0.6.0, 0.5.2, 0.5.1]`. +- The four directory mutations are **state actions**, so they carry + `ACTION_INTRODUCED_IN` entries at `0.7.0` (and are `@clientDispatchable`). + Everything else — the capability and the create-time / state fields — is gated + by the `multipleWorkingDirectories` capability plus the `initialize` version + handshake. +- Removal actions are idempotent and modelled as + *reconfigure-to-the-reduced-set*; a host MAY decline to apply a removal (e.g. + a directory still designated as some chat's primary), leaving the set + unchanged. + +--- + +## 9. Design decisions & resolved questions + +- **Primary is per-chat, read-only, fixed at creation.** *Resolved:* the set is + equal peers by default and the **session has no primary**. Rejected a + "primary + additional" split because it re-introduces the confusion the user + called out ("what's the relation between primary and secondary?"). An agent + that needs one distinguished root advertises `requiresPrimary`; the client + designates it **per chat** via `primaryWorkingDirectory` + (`createChat`, or `createSession` to seed the default chat). It is stored + read-only on `ChatState`/`ChatSummary` (user-visible, recoverable by late + subscribers) but has **no mutating action** and is not part of + `session/chatUpdated` — so it's visible yet immutable. "In state" and + "mutable" are orthogonal in AHP: mutability comes from an action existing, not + from where the value lives. Deliberately *not* positional — never inferred + from array index — and a role-on-entry shape was rejected because it would + couple the immutable primary to the mutable set's lifecycle. + +- **Hard-remove the singular `workingDirectory`.** *Resolved (per review):* + removing these fields is a breaking change, so the feature targets a MINOR + bump (`0.7.0`); the deprecated singular fields on + `CreateSessionParams` / `SessionMetadata` / `ChatState` / `ChatSummary` are + removed outright rather than kept as a shorthand. (Originally planned to ride + `0.6.0`'s breaking window, but `0.6.0` shipped without this feature.) + +- **Directory mutations are state actions, not commands.** *Resolved (per + review):* `workingDirectories` is a keyed collection, so it follows the + established `*/workingDirectorySet` + `*/workingDirectoryRemoved` action + convention (session and chat) with pure reducers, rather than + request/response commands. The set lives in state; clients observe the result + there. + +- **Chat narrows to a subset (not exactly one).** *Resolved:* an earlier draft + made a chat operate in exactly one directory; this was widened to "a subset ⊆ + the session's set," with absent meaning the whole set. Gated by the same + capability. + +- **No directory field on changesets.** *Resolved (per review):* an earlier + revision put a `Changeset.workingDirectory` scalar on the changeset and made + a multiroot host MUST group by directory. Dropped — a changeset can span + directories (a per-turn diff touches several), so a single-directory scalar + can't model the common case, and reviewers noted matching a file to a + directory is a simple client-side operation against the known directory list + (not a VCS-boundary problem). Per-directory presentation is therefore + optional: clients group a changeset's files themselves, and a host MAY expose + extra per-directory catalogue entries (already possible — the `changesets` + list is unbounded). A machine-readable per-directory association (e.g. a + `{workingDirectory}` template variable) is deferred to a follow-up if needed. + +- **Removal semantics.** *Resolved:* no single-remove primitive is assumed; the + `*/workingDirectoryRemoved` action reduces to the reduced set, so it is + idempotent and safe to retry. A host MAY decline (e.g. a directory still + designated as some chat's primary). + +--- + +## 10. Open questions / future axes + +- **Config-resolution context.** `resolveSessionConfig` / + `sessionConfigCompletions` still take a *singular* `workingDirectory` as the + context for resolving config (e.g. listing git branches for a worktree picker). + Whether — and how — to make config resolution multiroot-aware is left as a + follow-up. + +- **Per-directory rollups on the lightweight summary.** `ChangesSummary` is a + single aggregate today. If session-list UIs want per-repo badges without + subscribing, a `byDirectory` rollup could be added later — deferred to keep the + summary lightweight. + +- **Richer path policy.** Per-tool scoping, read-vs-write per directory, or + glob-level rules are deliberately out of scope and can layer on top. + +--- + +## 11. One-slide summary + +- **Before:** a session is scoped to one `workingDirectory`. +- **After:** a session owns a **set of equal-peer directories**; a **chat** works + in a **subset**; **changesets can span directories** (grouping is optional). +- **Why:** cross-directory / multi-repo / multi-root-workspace tasks are one + coherent piece of work, not N disconnected sessions. +- **How it stays safe:** capability-gated, additive fields; directory mutations + are idempotent client-dispatchable actions; removal is reconfigure-to-reduced-set. +- **What it is not:** not a permission model, not per-file scoping, not a + mandatory primary, not sub-sessions — those are separate/future axes. diff --git a/schema/actions.schema.json b/schema/actions.schema.json index 795cc5b9d..eebb5e579 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -233,9 +233,16 @@ "$ref": "#/$defs/ChatInteractivity", "description": "How the user can interact with this chat. See {@link ChatInteractivity}.\n\nSupports agent-team patterns where worker chats are read-only or hidden.\nAbsence defaults to {@link ChatInteractivity.Full} for backward\ncompatibility." }, - "workingDirectory": { + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "The subset of the session's working directories this chat uses.\nSee {@link ChatState.workingDirectories} for the full semantics." + }, + "primaryWorkingDirectory": { "$ref": "#/$defs/URI", - "description": "Optional per-chat working directory.\n\nIf absent, the chat inherits\n{@link SessionSummary.workingDirectory | the session's working directory}.\nSee {@link ChatState.workingDirectory} for usage notes." + "description": "The chat's primary working directory.\nSee {@link ChatState.primaryWorkingDirectory} for the full semantics." } }, "description": "Mutable summary fields that changed; omitted fields are unchanged.\n\nIdentity fields (`resource`) never change and MUST be omitted by\nsenders; receivers SHOULD ignore them if present." @@ -405,6 +412,40 @@ "clientId" ] }, + "SessionWorkingDirectorySetAction": { + "type": "object", + "description": "A working directory was added to the session's\n{@link SessionState.workingDirectories} set.\n\nMembership semantics keyed by the directory URI: the reducer appends\n`directory` when the set does not already contain it (creating the set if\nabsent) and is a no-op when it is already present. Only valid when the agent\nadvertises {@link AgentCapabilities.multipleWorkingDirectories}.", + "properties": { + "type": { + "const": "session/workingDirectorySet" + }, + "directory": { + "$ref": "#/$defs/URI", + "description": "The working directory to grant the session's agent tool access to." + } + }, + "required": [ + "type", + "directory" + ] + }, + "SessionWorkingDirectoryRemovedAction": { + "type": "object", + "description": "A working directory was removed from the session's\n{@link SessionState.workingDirectories} set.\n\nRemoves `directory` from the set; a no-op when it is not present. There is no\natomic backend \"remove one\" primitive — a host reconfigures its agent to the\nreduced set — so this action is safe to model as idempotent. A host MAY\ndecline to apply the removal (e.g. a directory still designated as some\nchat's {@link ChatState.primaryWorkingDirectory | primary}); it then leaves\nthe set unchanged.", + "properties": { + "type": { + "const": "session/workingDirectoryRemoved" + }, + "directory": { + "$ref": "#/$defs/URI", + "description": "The working directory to revoke the session's agent tool access to." + } + }, + "required": [ + "type", + "directory" + ] + }, "SessionInputNeededSetAction": { "type": "object", "description": "A session-level input request was added or updated.\n\nUpsert semantics keyed by {@link SessionInputRequest.id | `request.id`}: the\nhost dispatches this with the full {@link SessionInputRequest} to append a new\nentry to {@link SessionState.inputNeeded} or replace the existing entry with\nthe same `id`.\n\nServer-originated: the host mirrors chat-level requests (elicitations, tool\nconfirmations, client-tool executions) into the session aggregate so clients\nsubscribed only to the session channel can discover them. Clients respond by\ndispatching the ordinary `chat/*` action to the entry's `chat` channel — see\n{@link SessionInputRequest}.", @@ -1248,6 +1289,40 @@ "type" ] }, + "ChatWorkingDirectorySetAction": { + "type": "object", + "description": "A working directory was added to this chat's\n{@link ChatState.workingDirectories} subset.\n\nMembership semantics keyed by the directory URI: the reducer appends\n`directory` when the chat's subset does not already contain it (creating the\nsubset if absent) and is a no-op when it is already present. `directory` MUST\nbe one of the owning session's {@link SessionState.workingDirectories}; a host\nMUST reject a directory that is not. Only valid when the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}.", + "properties": { + "type": { + "const": "chat/workingDirectorySet" + }, + "directory": { + "$ref": "#/$defs/URI", + "description": "The working directory to add to this chat's subset." + } + }, + "required": [ + "type", + "directory" + ] + }, + "ChatWorkingDirectoryRemovedAction": { + "type": "object", + "description": "A working directory was removed from this chat's\n{@link ChatState.workingDirectories} subset.\n\nRemoves `directory` from the chat's subset; a no-op when it is not present.\nIdempotent, mirroring `session/workingDirectoryRemoved`. Only affects the\nchat's subset — the directory remains in the session's set.", + "properties": { + "type": { + "const": "chat/workingDirectoryRemoved" + }, + "directory": { + "$ref": "#/$defs/URI", + "description": "The working directory to remove from this chat's subset." + } + }, + "required": [ + "type", + "directory" + ] + }, "ChatUsageAction": { "type": "object", "description": "Token usage report for a turn.", @@ -2069,6 +2144,12 @@ { "$ref": "#/$defs/ChatActivityChangedAction" }, + { + "$ref": "#/$defs/ChatWorkingDirectorySetAction" + }, + { + "$ref": "#/$defs/ChatWorkingDirectoryRemovedAction" + }, { "$ref": "#/$defs/ChatUsageAction" }, @@ -2637,6 +2718,10 @@ "multipleChats": { "$ref": "#/$defs/MultipleChatsCapability", "description": "The agent can host more than one concurrent chat per session. When absent,\nclients MUST NOT call `createChat` to open chats beyond the default one the\nsession starts with. An empty object `{}` advertises multi-chat without\nforking; set {@link MultipleChatsCapability.fork} to also allow forking." + }, + "multipleWorkingDirectories": { + "$ref": "#/$defs/MultipleWorkingDirectoriesCapability", + "description": "The session's agent can be granted tool access to more than one working\ndirectory. The directories are treated as equal peers except where the\nagent advertises {@link MultipleWorkingDirectoriesCapability.requiresPrimary}\n(some backends need one directory designated as a primary root).\n\nWhen absent, clients MUST NOT mutate a session's or chat's working-directory\nset and MUST NOT set more than one entry in\n{@link CreateSessionParams.workingDirectories}." } } }, @@ -2650,6 +2735,16 @@ } } }, + "MultipleWorkingDirectoriesCapability": { + "type": "object", + "description": "Options for the {@link AgentCapabilities.multipleWorkingDirectories} capability.", + "properties": { + "requiresPrimary": { + "type": "boolean", + "description": "The agent requires each chat to designate one of its working directories as\nthe **primary** — a distinguished root the chat is centered on (e.g. the\nagent's process root for that chat, the default location for relative\npaths). Primary is a **per-chat** notion, fixed at chat creation. When\n`true`, a client SHOULD supply {@link CreateChatParams.primaryWorkingDirectory}\n(and {@link CreateSessionParams.primaryWorkingDirectory}, which seeds the\nsession's default chat); a host MAY reject creation that omits it, or fall\nback to the first entry of the chat's working directories. The chosen\nprimary is reported (read-only) on {@link ChatState.primaryWorkingDirectory}.\n\nWhen absent or `false`, the agent has no primary — all directories are\nequal peers and clients need not designate one." + } + } + }, "SessionModelInfo": { "type": "object", "properties": { @@ -2764,9 +2859,12 @@ "$ref": "#/$defs/ProjectInfo", "description": "Server-owned project for this session" }, - "workingDirectory": { - "$ref": "#/$defs/URI", - "description": "The default working directory URI for this session. Individual chats\nMAY override via {@link ChatSummary.workingDirectory | their own\n`workingDirectory`}; this field acts as the fallback for any chat that\ndoes not." + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "The working directories the session's agent has tool access to, as\nmaintained by the `session/workingDirectorySet` /\n`session/workingDirectoryRemoved` actions. Directories are **equal peers** —\nthe session has no primary. Individual chats MAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`} and\ndesignate one of their own directories as primary (see\n{@link ChatState.primaryWorkingDirectory}); a chat that sets no subset\noperates against this full set." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -2803,9 +2901,12 @@ "$ref": "#/$defs/ProjectInfo", "description": "Server-owned project for this session" }, - "workingDirectory": { - "$ref": "#/$defs/URI", - "description": "The default working directory URI for this session. Individual chats\nMAY override via {@link ChatSummary.workingDirectory | their own\n`workingDirectory`}; this field acts as the fallback for any chat that\ndoes not." + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "The working directories the session's agent has tool access to, as\nmaintained by the `session/workingDirectorySet` /\n`session/workingDirectoryRemoved` actions. Directories are **equal peers** —\nthe session has no primary. Individual chats MAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`} and\ndesignate one of their own directories as primary (see\n{@link ChatState.primaryWorkingDirectory}); a chat that sets no subset\noperates against this full set." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -2872,7 +2973,7 @@ "_meta": { "type": "object", "additionalProperties": {}, - "description": "Additional provider-specific metadata for this session.\n\nClients MAY look for well-known keys here to provide enhanced UI.\nFor example, a `git` key may provide extra git metadata about the session's\nworkingDirectory." + "description": "Additional provider-specific metadata for this session.\n\nClients MAY look for well-known keys here to provide enhanced UI.\nFor example, a `git` key may provide extra git metadata about the session's\nworking directories." } }, "required": [ @@ -3082,7 +3183,7 @@ }, "SessionSummary": { "type": "object", - "description": "Lightweight catalog entry summarizing one session. Surfaced via\n{@link RootChannelCommands.listSessions | `root/listSessions`} and\n`root/sessionAdded`/`root/sessionSummaryChanged` notifications.\n\n**Aggregation across chats.** Once a session contains more than one chat,\nseveral `SessionSummary` fields are derived from the underlying\n{@link SessionState.chats | chat catalog}. Producers SHOULD follow these\nrules so clients that only consume the session summary (e.g. a session\nlist) still see meaningful state:\n\n- `status`: take the activity bits (`Idle` / `InProgress` / `InputNeeded` /\n `Error` — bits 0–4) from the\n {@link SessionState.defaultChat | default chat} when present, else from\n the most recently modified chat. **Promote** `InputNeeded` whenever any\n chat in the session needs input, and **promote** `Error` whenever any\n chat is in an error state — both override the default-chat bits. The\n orthogonal flag bits (`IsRead`, `IsArchived`) remain session-scoped.\n- `activity`: mirror the activity string of the default chat, or of the\n chat currently driving the promoted status bits when a non-default chat\n wins (e.g. the chat that raised `InputNeeded`).\n- `modifiedAt`: the max of all chats' `modifiedAt`.\n- `workingDirectory`: the session-level **default**. Individual chats MAY\n override via {@link ChatSummary.workingDirectory}; aggregating these up\n is meaningless and SHOULD NOT be attempted.\n- `changes`: optional roll-up across all chats. Producers MAY sum the\n per-chat changeset stats or report the most expensive chat's stats —\n whichever is cheaper for the host to compute.\n\nSessions with a single chat trivially satisfy all of the above (the chat's\nvalues pass through unchanged). The rules only matter once a session\ncarries multiple chats.", + "description": "Lightweight catalog entry summarizing one session. Surfaced via\n{@link RootChannelCommands.listSessions | `root/listSessions`} and\n`root/sessionAdded`/`root/sessionSummaryChanged` notifications.\n\n**Aggregation across chats.** Once a session contains more than one chat,\nseveral `SessionSummary` fields are derived from the underlying\n{@link SessionState.chats | chat catalog}. Producers SHOULD follow these\nrules so clients that only consume the session summary (e.g. a session\nlist) still see meaningful state:\n\n- `status`: take the activity bits (`Idle` / `InProgress` / `InputNeeded` /\n `Error` — bits 0–4) from the\n {@link SessionState.defaultChat | default chat} when present, else from\n the most recently modified chat. **Promote** `InputNeeded` whenever any\n chat in the session needs input, and **promote** `Error` whenever any\n chat is in an error state — both override the default-chat bits. The\n orthogonal flag bits (`IsRead`, `IsArchived`) remain session-scoped.\n- `activity`: mirror the activity string of the default chat, or of the\n chat currently driving the promoted status bits when a non-default chat\n wins (e.g. the chat that raised `InputNeeded`).\n- `modifiedAt`: the max of all chats' `modifiedAt`.\n- `workingDirectories`: the session-level set. Individual chats MAY restrict\n to a subset via {@link ChatSummary.workingDirectories}; aggregating these\n up is meaningless and SHOULD NOT be attempted.\n- `changes`: optional roll-up across all chats. Producers MAY sum the\n per-chat changeset stats or report the most expensive chat's stats —\n whichever is cheaper for the host to compute.\n\nSessions with a single chat trivially satisfy all of the above (the chat's\nvalues pass through unchanged). The rules only matter once a session\ncarries multiple chats.", "properties": { "provider": { "type": "string", @@ -3104,9 +3205,12 @@ "$ref": "#/$defs/ProjectInfo", "description": "Server-owned project for this session" }, - "workingDirectory": { - "$ref": "#/$defs/URI", - "description": "The default working directory URI for this session. Individual chats\nMAY override via {@link ChatSummary.workingDirectory | their own\n`workingDirectory`}; this field acts as the fallback for any chat that\ndoes not." + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "The working directories the session's agent has tool access to, as\nmaintained by the `session/workingDirectorySet` /\n`session/workingDirectoryRemoved` actions. Directories are **equal peers** —\nthe session has no primary. Individual chats MAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`} and\ndesignate one of their own directories as primary (see\n{@link ChatState.primaryWorkingDirectory}); a chat that sets no subset\noperates against this full set." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -4386,9 +4490,16 @@ "$ref": "#/$defs/ChatInteractivity", "description": "How the user can interact with this chat. See {@link ChatInteractivity}.\n\nSupports agent-team patterns where worker chats are read-only or hidden.\nAbsence defaults to {@link ChatInteractivity.Full} for backward\ncompatibility." }, - "workingDirectory": { + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "The subset of the session's\n{@link SessionState.workingDirectories | `workingDirectories`} that this\nchat's agent has tool access to. Every entry MUST be present in the owning\nsession's `workingDirectories`; servers MUST reject a\n`chat/workingDirectorySet` action that violates this constraint.\n\nWhen absent, the chat inherits the full session set. When present but empty\n(not recommended), the chat has no working-directory tool access at all.\n\nDispatch `chat/workingDirectorySet` / `chat/workingDirectoryRemoved` to\nupdate the subset on a running chat." + }, + "primaryWorkingDirectory": { "$ref": "#/$defs/URI", - "description": "Optional per-chat working directory.\n\nIf absent, the chat inherits\n{@link SessionState.workingDirectory | the session's working directory}.\nHosts MAY override this for individual chats — for example, to give a\nsubordinate chat its own git worktree so multiple chats in a session can\nmake independent edits that the orchestrator later merges back." + "description": "The chat's primary working directory — the distinguished root this chat is\ncentered on (e.g. the agent's process root for this chat, the default\nlocation for relative paths). MUST be one of this chat's effective working\ndirectories ({@link workingDirectories}, or the session's set when that is\nabsent). Present when the agent advertises\n{@link MultipleWorkingDirectoriesCapability.requiresPrimary}.\n\n**Read-only and fixed at creation.** It is set from\n{@link CreateChatParams.primaryWorkingDirectory} (or, for the session's\ndefault chat, {@link CreateSessionParams.primaryWorkingDirectory}) and does\nnot change over the chat's lifetime — there is no action to mutate it, and\nit does not participate in `session/chatUpdated`." }, "turns": { "type": "array", @@ -4466,9 +4577,16 @@ "$ref": "#/$defs/ChatInteractivity", "description": "How the user can interact with this chat. See {@link ChatInteractivity}.\n\nSupports agent-team patterns where worker chats are read-only or hidden.\nAbsence defaults to {@link ChatInteractivity.Full} for backward\ncompatibility." }, - "workingDirectory": { + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "The subset of the session's working directories this chat uses.\nSee {@link ChatState.workingDirectories} for the full semantics." + }, + "primaryWorkingDirectory": { "$ref": "#/$defs/URI", - "description": "Optional per-chat working directory.\n\nIf absent, the chat inherits\n{@link SessionSummary.workingDirectory | the session's working directory}.\nSee {@link ChatState.workingDirectory} for usage notes." + "description": "The chat's primary working directory.\nSee {@link ChatState.primaryWorkingDirectory} for the full semantics." } }, "required": [ @@ -7335,6 +7453,12 @@ { "$ref": "#/$defs/SessionActiveClientRemovedAction" }, + { + "$ref": "#/$defs/SessionWorkingDirectorySetAction" + }, + { + "$ref": "#/$defs/SessionWorkingDirectoryRemovedAction" + }, { "$ref": "#/$defs/SessionInputNeededSetAction" }, @@ -7428,6 +7552,12 @@ { "$ref": "#/$defs/ChatActivityChangedAction" }, + { + "$ref": "#/$defs/ChatWorkingDirectorySetAction" + }, + { + "$ref": "#/$defs/ChatWorkingDirectoryRemovedAction" + }, { "$ref": "#/$defs/ChatUsageAction" }, diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 0335c18ea..231e924a1 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -962,9 +962,16 @@ "type": "string", "description": "Agent provider ID" }, - "workingDirectory": { + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "The working directories the session's agent is granted tool access to.\nA session may span multiple directories; they are equal peers except when\nthe agent advertises\n{@link MultipleWorkingDirectoriesCapability.requiresPrimary}, in which case\none of them should be designated the primary via\n{@link primaryWorkingDirectory}.\n\nA client MUST NOT supply more than one entry unless the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}; a server without that\ncapability treats only the first entry as the session's working directory\nand ignores the rest. Dispatch `session/workingDirectorySet` /\n`session/workingDirectoryRemoved` to change the set after the session has\nstarted.\n\nIgnored for forked sessions — a fork inherits its working directories\nfrom the source session identified by `fork`." + }, + "primaryWorkingDirectory": { "$ref": "#/$defs/URI", - "description": "Working directory for the session" + "description": "The primary working directory for the session's **default chat**.\n\nA session has no primary of its own — primary is a per-chat notion (see\n{@link ChatState.primaryWorkingDirectory}). But `createSession` implicitly\ncreates the session's default chat, and there is no separate `createChat`\ncall to carry that chat's create-time fields. This field is therefore the\nonly place a client can designate the **default chat's** primary at birth;\nit is copied into that chat's read-only `primaryWorkingDirectory`. For any\nnon-default chat, pass {@link CreateChatParams.primaryWorkingDirectory}\ninstead.\n\nWhen set, it MUST be one of {@link workingDirectories}. A client SHOULD\nsupply this when the agent advertises\n{@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY\nreject creation that omits it, or fall back to the first entry of\n`workingDirectories`. Ignored for forked sessions (a fork inherits the\nsource session's chats and their primaries)." }, "fork": { "$ref": "#/$defs/SessionForkSource", @@ -1130,6 +1137,17 @@ "source": { "$ref": "#/$defs/ChatForkSource", "description": "Optional source chat and turn to fork from." + }, + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "Initial working-directory subset for this chat. Every entry MUST be\npresent in the owning session's `workingDirectories`; the server MUST\nreject any entry that is not. When absent, the chat inherits the full\nsession set. Forked chats (`source`) inherit the source chat's\n`workingDirectories`; this field is ignored for forked chats.\n\nA client MUST NOT supply this field unless the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}." + }, + "primaryWorkingDirectory": { + "$ref": "#/$defs/URI", + "description": "The chat's primary working directory — the distinguished root this chat is\ncentered on. When set, it MUST be one of the chat's effective working\ndirectories ({@link workingDirectories}, or the session's set when that is\nomitted). A client SHOULD supply this when the agent advertises\n{@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY\nreject creation that omits it, or fall back to the first of the chat's\ndirectories. Fixed at creation and reported (read-only) on\n{@link ChatState.primaryWorkingDirectory}. Ignored for forked chats (a fork\ninherits the source chat's primary)." } }, "required": [ @@ -1843,6 +1861,10 @@ "multipleChats": { "$ref": "#/$defs/MultipleChatsCapability", "description": "The agent can host more than one concurrent chat per session. When absent,\nclients MUST NOT call `createChat` to open chats beyond the default one the\nsession starts with. An empty object `{}` advertises multi-chat without\nforking; set {@link MultipleChatsCapability.fork} to also allow forking." + }, + "multipleWorkingDirectories": { + "$ref": "#/$defs/MultipleWorkingDirectoriesCapability", + "description": "The session's agent can be granted tool access to more than one working\ndirectory. The directories are treated as equal peers except where the\nagent advertises {@link MultipleWorkingDirectoriesCapability.requiresPrimary}\n(some backends need one directory designated as a primary root).\n\nWhen absent, clients MUST NOT mutate a session's or chat's working-directory\nset and MUST NOT set more than one entry in\n{@link CreateSessionParams.workingDirectories}." } } }, @@ -1856,6 +1878,16 @@ } } }, + "MultipleWorkingDirectoriesCapability": { + "type": "object", + "description": "Options for the {@link AgentCapabilities.multipleWorkingDirectories} capability.", + "properties": { + "requiresPrimary": { + "type": "boolean", + "description": "The agent requires each chat to designate one of its working directories as\nthe **primary** — a distinguished root the chat is centered on (e.g. the\nagent's process root for that chat, the default location for relative\npaths). Primary is a **per-chat** notion, fixed at chat creation. When\n`true`, a client SHOULD supply {@link CreateChatParams.primaryWorkingDirectory}\n(and {@link CreateSessionParams.primaryWorkingDirectory}, which seeds the\nsession's default chat); a host MAY reject creation that omits it, or fall\nback to the first entry of the chat's working directories. The chosen\nprimary is reported (read-only) on {@link ChatState.primaryWorkingDirectory}.\n\nWhen absent or `false`, the agent has no primary — all directories are\nequal peers and clients need not designate one." + } + } + }, "SessionModelInfo": { "type": "object", "properties": { @@ -1970,9 +2002,12 @@ "$ref": "#/$defs/ProjectInfo", "description": "Server-owned project for this session" }, - "workingDirectory": { - "$ref": "#/$defs/URI", - "description": "The default working directory URI for this session. Individual chats\nMAY override via {@link ChatSummary.workingDirectory | their own\n`workingDirectory`}; this field acts as the fallback for any chat that\ndoes not." + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "The working directories the session's agent has tool access to, as\nmaintained by the `session/workingDirectorySet` /\n`session/workingDirectoryRemoved` actions. Directories are **equal peers** —\nthe session has no primary. Individual chats MAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`} and\ndesignate one of their own directories as primary (see\n{@link ChatState.primaryWorkingDirectory}); a chat that sets no subset\noperates against this full set." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -2009,9 +2044,12 @@ "$ref": "#/$defs/ProjectInfo", "description": "Server-owned project for this session" }, - "workingDirectory": { - "$ref": "#/$defs/URI", - "description": "The default working directory URI for this session. Individual chats\nMAY override via {@link ChatSummary.workingDirectory | their own\n`workingDirectory`}; this field acts as the fallback for any chat that\ndoes not." + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "The working directories the session's agent has tool access to, as\nmaintained by the `session/workingDirectorySet` /\n`session/workingDirectoryRemoved` actions. Directories are **equal peers** —\nthe session has no primary. Individual chats MAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`} and\ndesignate one of their own directories as primary (see\n{@link ChatState.primaryWorkingDirectory}); a chat that sets no subset\noperates against this full set." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -2078,7 +2116,7 @@ "_meta": { "type": "object", "additionalProperties": {}, - "description": "Additional provider-specific metadata for this session.\n\nClients MAY look for well-known keys here to provide enhanced UI.\nFor example, a `git` key may provide extra git metadata about the session's\nworkingDirectory." + "description": "Additional provider-specific metadata for this session.\n\nClients MAY look for well-known keys here to provide enhanced UI.\nFor example, a `git` key may provide extra git metadata about the session's\nworking directories." } }, "required": [ @@ -2288,7 +2326,7 @@ }, "SessionSummary": { "type": "object", - "description": "Lightweight catalog entry summarizing one session. Surfaced via\n{@link RootChannelCommands.listSessions | `root/listSessions`} and\n`root/sessionAdded`/`root/sessionSummaryChanged` notifications.\n\n**Aggregation across chats.** Once a session contains more than one chat,\nseveral `SessionSummary` fields are derived from the underlying\n{@link SessionState.chats | chat catalog}. Producers SHOULD follow these\nrules so clients that only consume the session summary (e.g. a session\nlist) still see meaningful state:\n\n- `status`: take the activity bits (`Idle` / `InProgress` / `InputNeeded` /\n `Error` — bits 0–4) from the\n {@link SessionState.defaultChat | default chat} when present, else from\n the most recently modified chat. **Promote** `InputNeeded` whenever any\n chat in the session needs input, and **promote** `Error` whenever any\n chat is in an error state — both override the default-chat bits. The\n orthogonal flag bits (`IsRead`, `IsArchived`) remain session-scoped.\n- `activity`: mirror the activity string of the default chat, or of the\n chat currently driving the promoted status bits when a non-default chat\n wins (e.g. the chat that raised `InputNeeded`).\n- `modifiedAt`: the max of all chats' `modifiedAt`.\n- `workingDirectory`: the session-level **default**. Individual chats MAY\n override via {@link ChatSummary.workingDirectory}; aggregating these up\n is meaningless and SHOULD NOT be attempted.\n- `changes`: optional roll-up across all chats. Producers MAY sum the\n per-chat changeset stats or report the most expensive chat's stats —\n whichever is cheaper for the host to compute.\n\nSessions with a single chat trivially satisfy all of the above (the chat's\nvalues pass through unchanged). The rules only matter once a session\ncarries multiple chats.", + "description": "Lightweight catalog entry summarizing one session. Surfaced via\n{@link RootChannelCommands.listSessions | `root/listSessions`} and\n`root/sessionAdded`/`root/sessionSummaryChanged` notifications.\n\n**Aggregation across chats.** Once a session contains more than one chat,\nseveral `SessionSummary` fields are derived from the underlying\n{@link SessionState.chats | chat catalog}. Producers SHOULD follow these\nrules so clients that only consume the session summary (e.g. a session\nlist) still see meaningful state:\n\n- `status`: take the activity bits (`Idle` / `InProgress` / `InputNeeded` /\n `Error` — bits 0–4) from the\n {@link SessionState.defaultChat | default chat} when present, else from\n the most recently modified chat. **Promote** `InputNeeded` whenever any\n chat in the session needs input, and **promote** `Error` whenever any\n chat is in an error state — both override the default-chat bits. The\n orthogonal flag bits (`IsRead`, `IsArchived`) remain session-scoped.\n- `activity`: mirror the activity string of the default chat, or of the\n chat currently driving the promoted status bits when a non-default chat\n wins (e.g. the chat that raised `InputNeeded`).\n- `modifiedAt`: the max of all chats' `modifiedAt`.\n- `workingDirectories`: the session-level set. Individual chats MAY restrict\n to a subset via {@link ChatSummary.workingDirectories}; aggregating these\n up is meaningless and SHOULD NOT be attempted.\n- `changes`: optional roll-up across all chats. Producers MAY sum the\n per-chat changeset stats or report the most expensive chat's stats —\n whichever is cheaper for the host to compute.\n\nSessions with a single chat trivially satisfy all of the above (the chat's\nvalues pass through unchanged). The rules only matter once a session\ncarries multiple chats.", "properties": { "provider": { "type": "string", @@ -2310,9 +2348,12 @@ "$ref": "#/$defs/ProjectInfo", "description": "Server-owned project for this session" }, - "workingDirectory": { - "$ref": "#/$defs/URI", - "description": "The default working directory URI for this session. Individual chats\nMAY override via {@link ChatSummary.workingDirectory | their own\n`workingDirectory`}; this field acts as the fallback for any chat that\ndoes not." + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "The working directories the session's agent has tool access to, as\nmaintained by the `session/workingDirectorySet` /\n`session/workingDirectoryRemoved` actions. Directories are **equal peers** —\nthe session has no primary. Individual chats MAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`} and\ndesignate one of their own directories as primary (see\n{@link ChatState.primaryWorkingDirectory}); a chat that sets no subset\noperates against this full set." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -3592,9 +3633,16 @@ "$ref": "#/$defs/ChatInteractivity", "description": "How the user can interact with this chat. See {@link ChatInteractivity}.\n\nSupports agent-team patterns where worker chats are read-only or hidden.\nAbsence defaults to {@link ChatInteractivity.Full} for backward\ncompatibility." }, - "workingDirectory": { + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "The subset of the session's\n{@link SessionState.workingDirectories | `workingDirectories`} that this\nchat's agent has tool access to. Every entry MUST be present in the owning\nsession's `workingDirectories`; servers MUST reject a\n`chat/workingDirectorySet` action that violates this constraint.\n\nWhen absent, the chat inherits the full session set. When present but empty\n(not recommended), the chat has no working-directory tool access at all.\n\nDispatch `chat/workingDirectorySet` / `chat/workingDirectoryRemoved` to\nupdate the subset on a running chat." + }, + "primaryWorkingDirectory": { "$ref": "#/$defs/URI", - "description": "Optional per-chat working directory.\n\nIf absent, the chat inherits\n{@link SessionState.workingDirectory | the session's working directory}.\nHosts MAY override this for individual chats — for example, to give a\nsubordinate chat its own git worktree so multiple chats in a session can\nmake independent edits that the orchestrator later merges back." + "description": "The chat's primary working directory — the distinguished root this chat is\ncentered on (e.g. the agent's process root for this chat, the default\nlocation for relative paths). MUST be one of this chat's effective working\ndirectories ({@link workingDirectories}, or the session's set when that is\nabsent). Present when the agent advertises\n{@link MultipleWorkingDirectoriesCapability.requiresPrimary}.\n\n**Read-only and fixed at creation.** It is set from\n{@link CreateChatParams.primaryWorkingDirectory} (or, for the session's\ndefault chat, {@link CreateSessionParams.primaryWorkingDirectory}) and does\nnot change over the chat's lifetime — there is no action to mutate it, and\nit does not participate in `session/chatUpdated`." }, "turns": { "type": "array", @@ -3672,9 +3720,16 @@ "$ref": "#/$defs/ChatInteractivity", "description": "How the user can interact with this chat. See {@link ChatInteractivity}.\n\nSupports agent-team patterns where worker chats are read-only or hidden.\nAbsence defaults to {@link ChatInteractivity.Full} for backward\ncompatibility." }, - "workingDirectory": { + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "The subset of the session's working directories this chat uses.\nSee {@link ChatState.workingDirectories} for the full semantics." + }, + "primaryWorkingDirectory": { "$ref": "#/$defs/URI", - "description": "Optional per-chat working directory.\n\nIf absent, the chat inherits\n{@link SessionSummary.workingDirectory | the session's working directory}.\nSee {@link ChatState.workingDirectory} for usage notes." + "description": "The chat's primary working directory.\nSee {@link ChatState.primaryWorkingDirectory} for the full semantics." } }, "required": [ @@ -6323,9 +6378,16 @@ "$ref": "#/$defs/ChatInteractivity", "description": "How the user can interact with this chat. See {@link ChatInteractivity}.\n\nSupports agent-team patterns where worker chats are read-only or hidden.\nAbsence defaults to {@link ChatInteractivity.Full} for backward\ncompatibility." }, - "workingDirectory": { + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "The subset of the session's working directories this chat uses.\nSee {@link ChatState.workingDirectories} for the full semantics." + }, + "primaryWorkingDirectory": { "$ref": "#/$defs/URI", - "description": "Optional per-chat working directory.\n\nIf absent, the chat inherits\n{@link SessionSummary.workingDirectory | the session's working directory}.\nSee {@link ChatState.workingDirectory} for usage notes." + "description": "The chat's primary working directory.\nSee {@link ChatState.primaryWorkingDirectory} for the full semantics." } }, "description": "Mutable summary fields that changed; omitted fields are unchanged.\n\nIdentity fields (`resource`) never change and MUST be omitted by\nsenders; receivers SHOULD ignore them if present." @@ -6495,6 +6557,40 @@ "clientId" ] }, + "SessionWorkingDirectorySetAction": { + "type": "object", + "description": "A working directory was added to the session's\n{@link SessionState.workingDirectories} set.\n\nMembership semantics keyed by the directory URI: the reducer appends\n`directory` when the set does not already contain it (creating the set if\nabsent) and is a no-op when it is already present. Only valid when the agent\nadvertises {@link AgentCapabilities.multipleWorkingDirectories}.", + "properties": { + "type": { + "const": "session/workingDirectorySet" + }, + "directory": { + "$ref": "#/$defs/URI", + "description": "The working directory to grant the session's agent tool access to." + } + }, + "required": [ + "type", + "directory" + ] + }, + "SessionWorkingDirectoryRemovedAction": { + "type": "object", + "description": "A working directory was removed from the session's\n{@link SessionState.workingDirectories} set.\n\nRemoves `directory` from the set; a no-op when it is not present. There is no\natomic backend \"remove one\" primitive — a host reconfigures its agent to the\nreduced set — so this action is safe to model as idempotent. A host MAY\ndecline to apply the removal (e.g. a directory still designated as some\nchat's {@link ChatState.primaryWorkingDirectory | primary}); it then leaves\nthe set unchanged.", + "properties": { + "type": { + "const": "session/workingDirectoryRemoved" + }, + "directory": { + "$ref": "#/$defs/URI", + "description": "The working directory to revoke the session's agent tool access to." + } + }, + "required": [ + "type", + "directory" + ] + }, "SessionInputNeededSetAction": { "type": "object", "description": "A session-level input request was added or updated.\n\nUpsert semantics keyed by {@link SessionInputRequest.id | `request.id`}: the\nhost dispatches this with the full {@link SessionInputRequest} to append a new\nentry to {@link SessionState.inputNeeded} or replace the existing entry with\nthe same `id`.\n\nServer-originated: the host mirrors chat-level requests (elicitations, tool\nconfirmations, client-tool executions) into the session aggregate so clients\nsubscribed only to the session channel can discover them. Clients respond by\ndispatching the ordinary `chat/*` action to the entry's `chat` channel — see\n{@link SessionInputRequest}.", @@ -7338,6 +7434,40 @@ "type" ] }, + "ChatWorkingDirectorySetAction": { + "type": "object", + "description": "A working directory was added to this chat's\n{@link ChatState.workingDirectories} subset.\n\nMembership semantics keyed by the directory URI: the reducer appends\n`directory` when the chat's subset does not already contain it (creating the\nsubset if absent) and is a no-op when it is already present. `directory` MUST\nbe one of the owning session's {@link SessionState.workingDirectories}; a host\nMUST reject a directory that is not. Only valid when the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}.", + "properties": { + "type": { + "const": "chat/workingDirectorySet" + }, + "directory": { + "$ref": "#/$defs/URI", + "description": "The working directory to add to this chat's subset." + } + }, + "required": [ + "type", + "directory" + ] + }, + "ChatWorkingDirectoryRemovedAction": { + "type": "object", + "description": "A working directory was removed from this chat's\n{@link ChatState.workingDirectories} subset.\n\nRemoves `directory` from the chat's subset; a no-op when it is not present.\nIdempotent, mirroring `session/workingDirectoryRemoved`. Only affects the\nchat's subset — the directory remains in the session's set.", + "properties": { + "type": { + "const": "chat/workingDirectoryRemoved" + }, + "directory": { + "$ref": "#/$defs/URI", + "description": "The working directory to remove from this chat's subset." + } + }, + "required": [ + "type", + "directory" + ] + }, "ChatUsageAction": { "type": "object", "description": "Token usage report for a turn.", @@ -8146,6 +8276,12 @@ { "$ref": "#/$defs/SessionActiveClientRemovedAction" }, + { + "$ref": "#/$defs/SessionWorkingDirectorySetAction" + }, + { + "$ref": "#/$defs/SessionWorkingDirectoryRemovedAction" + }, { "$ref": "#/$defs/SessionInputNeededSetAction" }, @@ -8239,6 +8375,12 @@ { "$ref": "#/$defs/ChatActivityChangedAction" }, + { + "$ref": "#/$defs/ChatWorkingDirectorySetAction" + }, + { + "$ref": "#/$defs/ChatWorkingDirectoryRemovedAction" + }, { "$ref": "#/$defs/ChatUsageAction" }, diff --git a/schema/errors.schema.json b/schema/errors.schema.json index 468b76256..5504cfcbd 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -627,6 +627,10 @@ "multipleChats": { "$ref": "#/$defs/MultipleChatsCapability", "description": "The agent can host more than one concurrent chat per session. When absent,\nclients MUST NOT call `createChat` to open chats beyond the default one the\nsession starts with. An empty object `{}` advertises multi-chat without\nforking; set {@link MultipleChatsCapability.fork} to also allow forking." + }, + "multipleWorkingDirectories": { + "$ref": "#/$defs/MultipleWorkingDirectoriesCapability", + "description": "The session's agent can be granted tool access to more than one working\ndirectory. The directories are treated as equal peers except where the\nagent advertises {@link MultipleWorkingDirectoriesCapability.requiresPrimary}\n(some backends need one directory designated as a primary root).\n\nWhen absent, clients MUST NOT mutate a session's or chat's working-directory\nset and MUST NOT set more than one entry in\n{@link CreateSessionParams.workingDirectories}." } } }, @@ -640,6 +644,16 @@ } } }, + "MultipleWorkingDirectoriesCapability": { + "type": "object", + "description": "Options for the {@link AgentCapabilities.multipleWorkingDirectories} capability.", + "properties": { + "requiresPrimary": { + "type": "boolean", + "description": "The agent requires each chat to designate one of its working directories as\nthe **primary** — a distinguished root the chat is centered on (e.g. the\nagent's process root for that chat, the default location for relative\npaths). Primary is a **per-chat** notion, fixed at chat creation. When\n`true`, a client SHOULD supply {@link CreateChatParams.primaryWorkingDirectory}\n(and {@link CreateSessionParams.primaryWorkingDirectory}, which seeds the\nsession's default chat); a host MAY reject creation that omits it, or fall\nback to the first entry of the chat's working directories. The chosen\nprimary is reported (read-only) on {@link ChatState.primaryWorkingDirectory}.\n\nWhen absent or `false`, the agent has no primary — all directories are\nequal peers and clients need not designate one." + } + } + }, "SessionModelInfo": { "type": "object", "properties": { @@ -754,9 +768,12 @@ "$ref": "#/$defs/ProjectInfo", "description": "Server-owned project for this session" }, - "workingDirectory": { - "$ref": "#/$defs/URI", - "description": "The default working directory URI for this session. Individual chats\nMAY override via {@link ChatSummary.workingDirectory | their own\n`workingDirectory`}; this field acts as the fallback for any chat that\ndoes not." + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "The working directories the session's agent has tool access to, as\nmaintained by the `session/workingDirectorySet` /\n`session/workingDirectoryRemoved` actions. Directories are **equal peers** —\nthe session has no primary. Individual chats MAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`} and\ndesignate one of their own directories as primary (see\n{@link ChatState.primaryWorkingDirectory}); a chat that sets no subset\noperates against this full set." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -793,9 +810,12 @@ "$ref": "#/$defs/ProjectInfo", "description": "Server-owned project for this session" }, - "workingDirectory": { - "$ref": "#/$defs/URI", - "description": "The default working directory URI for this session. Individual chats\nMAY override via {@link ChatSummary.workingDirectory | their own\n`workingDirectory`}; this field acts as the fallback for any chat that\ndoes not." + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "The working directories the session's agent has tool access to, as\nmaintained by the `session/workingDirectorySet` /\n`session/workingDirectoryRemoved` actions. Directories are **equal peers** —\nthe session has no primary. Individual chats MAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`} and\ndesignate one of their own directories as primary (see\n{@link ChatState.primaryWorkingDirectory}); a chat that sets no subset\noperates against this full set." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -862,7 +882,7 @@ "_meta": { "type": "object", "additionalProperties": {}, - "description": "Additional provider-specific metadata for this session.\n\nClients MAY look for well-known keys here to provide enhanced UI.\nFor example, a `git` key may provide extra git metadata about the session's\nworkingDirectory." + "description": "Additional provider-specific metadata for this session.\n\nClients MAY look for well-known keys here to provide enhanced UI.\nFor example, a `git` key may provide extra git metadata about the session's\nworking directories." } }, "required": [ @@ -1072,7 +1092,7 @@ }, "SessionSummary": { "type": "object", - "description": "Lightweight catalog entry summarizing one session. Surfaced via\n{@link RootChannelCommands.listSessions | `root/listSessions`} and\n`root/sessionAdded`/`root/sessionSummaryChanged` notifications.\n\n**Aggregation across chats.** Once a session contains more than one chat,\nseveral `SessionSummary` fields are derived from the underlying\n{@link SessionState.chats | chat catalog}. Producers SHOULD follow these\nrules so clients that only consume the session summary (e.g. a session\nlist) still see meaningful state:\n\n- `status`: take the activity bits (`Idle` / `InProgress` / `InputNeeded` /\n `Error` — bits 0–4) from the\n {@link SessionState.defaultChat | default chat} when present, else from\n the most recently modified chat. **Promote** `InputNeeded` whenever any\n chat in the session needs input, and **promote** `Error` whenever any\n chat is in an error state — both override the default-chat bits. The\n orthogonal flag bits (`IsRead`, `IsArchived`) remain session-scoped.\n- `activity`: mirror the activity string of the default chat, or of the\n chat currently driving the promoted status bits when a non-default chat\n wins (e.g. the chat that raised `InputNeeded`).\n- `modifiedAt`: the max of all chats' `modifiedAt`.\n- `workingDirectory`: the session-level **default**. Individual chats MAY\n override via {@link ChatSummary.workingDirectory}; aggregating these up\n is meaningless and SHOULD NOT be attempted.\n- `changes`: optional roll-up across all chats. Producers MAY sum the\n per-chat changeset stats or report the most expensive chat's stats —\n whichever is cheaper for the host to compute.\n\nSessions with a single chat trivially satisfy all of the above (the chat's\nvalues pass through unchanged). The rules only matter once a session\ncarries multiple chats.", + "description": "Lightweight catalog entry summarizing one session. Surfaced via\n{@link RootChannelCommands.listSessions | `root/listSessions`} and\n`root/sessionAdded`/`root/sessionSummaryChanged` notifications.\n\n**Aggregation across chats.** Once a session contains more than one chat,\nseveral `SessionSummary` fields are derived from the underlying\n{@link SessionState.chats | chat catalog}. Producers SHOULD follow these\nrules so clients that only consume the session summary (e.g. a session\nlist) still see meaningful state:\n\n- `status`: take the activity bits (`Idle` / `InProgress` / `InputNeeded` /\n `Error` — bits 0–4) from the\n {@link SessionState.defaultChat | default chat} when present, else from\n the most recently modified chat. **Promote** `InputNeeded` whenever any\n chat in the session needs input, and **promote** `Error` whenever any\n chat is in an error state — both override the default-chat bits. The\n orthogonal flag bits (`IsRead`, `IsArchived`) remain session-scoped.\n- `activity`: mirror the activity string of the default chat, or of the\n chat currently driving the promoted status bits when a non-default chat\n wins (e.g. the chat that raised `InputNeeded`).\n- `modifiedAt`: the max of all chats' `modifiedAt`.\n- `workingDirectories`: the session-level set. Individual chats MAY restrict\n to a subset via {@link ChatSummary.workingDirectories}; aggregating these\n up is meaningless and SHOULD NOT be attempted.\n- `changes`: optional roll-up across all chats. Producers MAY sum the\n per-chat changeset stats or report the most expensive chat's stats —\n whichever is cheaper for the host to compute.\n\nSessions with a single chat trivially satisfy all of the above (the chat's\nvalues pass through unchanged). The rules only matter once a session\ncarries multiple chats.", "properties": { "provider": { "type": "string", @@ -1094,9 +1114,12 @@ "$ref": "#/$defs/ProjectInfo", "description": "Server-owned project for this session" }, - "workingDirectory": { - "$ref": "#/$defs/URI", - "description": "The default working directory URI for this session. Individual chats\nMAY override via {@link ChatSummary.workingDirectory | their own\n`workingDirectory`}; this field acts as the fallback for any chat that\ndoes not." + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "The working directories the session's agent has tool access to, as\nmaintained by the `session/workingDirectorySet` /\n`session/workingDirectoryRemoved` actions. Directories are **equal peers** —\nthe session has no primary. Individual chats MAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`} and\ndesignate one of their own directories as primary (see\n{@link ChatState.primaryWorkingDirectory}); a chat that sets no subset\noperates against this full set." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -2376,9 +2399,16 @@ "$ref": "#/$defs/ChatInteractivity", "description": "How the user can interact with this chat. See {@link ChatInteractivity}.\n\nSupports agent-team patterns where worker chats are read-only or hidden.\nAbsence defaults to {@link ChatInteractivity.Full} for backward\ncompatibility." }, - "workingDirectory": { + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "The subset of the session's\n{@link SessionState.workingDirectories | `workingDirectories`} that this\nchat's agent has tool access to. Every entry MUST be present in the owning\nsession's `workingDirectories`; servers MUST reject a\n`chat/workingDirectorySet` action that violates this constraint.\n\nWhen absent, the chat inherits the full session set. When present but empty\n(not recommended), the chat has no working-directory tool access at all.\n\nDispatch `chat/workingDirectorySet` / `chat/workingDirectoryRemoved` to\nupdate the subset on a running chat." + }, + "primaryWorkingDirectory": { "$ref": "#/$defs/URI", - "description": "Optional per-chat working directory.\n\nIf absent, the chat inherits\n{@link SessionState.workingDirectory | the session's working directory}.\nHosts MAY override this for individual chats — for example, to give a\nsubordinate chat its own git worktree so multiple chats in a session can\nmake independent edits that the orchestrator later merges back." + "description": "The chat's primary working directory — the distinguished root this chat is\ncentered on (e.g. the agent's process root for this chat, the default\nlocation for relative paths). MUST be one of this chat's effective working\ndirectories ({@link workingDirectories}, or the session's set when that is\nabsent). Present when the agent advertises\n{@link MultipleWorkingDirectoriesCapability.requiresPrimary}.\n\n**Read-only and fixed at creation.** It is set from\n{@link CreateChatParams.primaryWorkingDirectory} (or, for the session's\ndefault chat, {@link CreateSessionParams.primaryWorkingDirectory}) and does\nnot change over the chat's lifetime — there is no action to mutate it, and\nit does not participate in `session/chatUpdated`." }, "turns": { "type": "array", @@ -2456,9 +2486,16 @@ "$ref": "#/$defs/ChatInteractivity", "description": "How the user can interact with this chat. See {@link ChatInteractivity}.\n\nSupports agent-team patterns where worker chats are read-only or hidden.\nAbsence defaults to {@link ChatInteractivity.Full} for backward\ncompatibility." }, - "workingDirectory": { + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "The subset of the session's working directories this chat uses.\nSee {@link ChatState.workingDirectories} for the full semantics." + }, + "primaryWorkingDirectory": { "$ref": "#/$defs/URI", - "description": "Optional per-chat working directory.\n\nIf absent, the chat inherits\n{@link SessionSummary.workingDirectory | the session's working directory}.\nSee {@link ChatState.workingDirectory} for usage notes." + "description": "The chat's primary working directory.\nSee {@link ChatState.primaryWorkingDirectory} for the full semantics." } }, "required": [ @@ -5836,9 +5873,16 @@ "type": "string", "description": "Agent provider ID" }, - "workingDirectory": { + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "The working directories the session's agent is granted tool access to.\nA session may span multiple directories; they are equal peers except when\nthe agent advertises\n{@link MultipleWorkingDirectoriesCapability.requiresPrimary}, in which case\none of them should be designated the primary via\n{@link primaryWorkingDirectory}.\n\nA client MUST NOT supply more than one entry unless the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}; a server without that\ncapability treats only the first entry as the session's working directory\nand ignores the rest. Dispatch `session/workingDirectorySet` /\n`session/workingDirectoryRemoved` to change the set after the session has\nstarted.\n\nIgnored for forked sessions — a fork inherits its working directories\nfrom the source session identified by `fork`." + }, + "primaryWorkingDirectory": { "$ref": "#/$defs/URI", - "description": "Working directory for the session" + "description": "The primary working directory for the session's **default chat**.\n\nA session has no primary of its own — primary is a per-chat notion (see\n{@link ChatState.primaryWorkingDirectory}). But `createSession` implicitly\ncreates the session's default chat, and there is no separate `createChat`\ncall to carry that chat's create-time fields. This field is therefore the\nonly place a client can designate the **default chat's** primary at birth;\nit is copied into that chat's read-only `primaryWorkingDirectory`. For any\nnon-default chat, pass {@link CreateChatParams.primaryWorkingDirectory}\ninstead.\n\nWhen set, it MUST be one of {@link workingDirectories}. A client SHOULD\nsupply this when the agent advertises\n{@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY\nreject creation that omits it, or fall back to the first entry of\n`workingDirectories`. Ignored for forked sessions (a fork inherits the\nsource session's chats and their primaries)." }, "fork": { "$ref": "#/$defs/SessionForkSource", @@ -6004,6 +6048,17 @@ "source": { "$ref": "#/$defs/ChatForkSource", "description": "Optional source chat and turn to fork from." + }, + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "Initial working-directory subset for this chat. Every entry MUST be\npresent in the owning session's `workingDirectories`; the server MUST\nreject any entry that is not. When absent, the chat inherits the full\nsession set. Forked chats (`source`) inherit the source chat's\n`workingDirectories`; this field is ignored for forked chats.\n\nA client MUST NOT supply this field unless the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}." + }, + "primaryWorkingDirectory": { + "$ref": "#/$defs/URI", + "description": "The chat's primary working directory — the distinguished root this chat is\ncentered on. When set, it MUST be one of the chat's effective working\ndirectories ({@link workingDirectories}, or the session's set when that is\nomitted). A client SHOULD supply this when the agent advertises\n{@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY\nreject creation that omits it, or fall back to the first of the chat's\ndirectories. Fixed at creation and reported (read-only) on\n{@link ChatState.primaryWorkingDirectory}. Ignored for forked chats (a fork\ninherits the source chat's primary)." } }, "required": [ @@ -6804,6 +6859,12 @@ { "$ref": "#/$defs/SessionActiveClientRemovedAction" }, + { + "$ref": "#/$defs/SessionWorkingDirectorySetAction" + }, + { + "$ref": "#/$defs/SessionWorkingDirectoryRemovedAction" + }, { "$ref": "#/$defs/SessionInputNeededSetAction" }, @@ -6897,6 +6958,12 @@ { "$ref": "#/$defs/ChatActivityChangedAction" }, + { + "$ref": "#/$defs/ChatWorkingDirectorySetAction" + }, + { + "$ref": "#/$defs/ChatWorkingDirectoryRemovedAction" + }, { "$ref": "#/$defs/ChatUsageAction" }, @@ -7286,9 +7353,16 @@ "$ref": "#/$defs/ChatInteractivity", "description": "How the user can interact with this chat. See {@link ChatInteractivity}.\n\nSupports agent-team patterns where worker chats are read-only or hidden.\nAbsence defaults to {@link ChatInteractivity.Full} for backward\ncompatibility." }, - "workingDirectory": { + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "The subset of the session's working directories this chat uses.\nSee {@link ChatState.workingDirectories} for the full semantics." + }, + "primaryWorkingDirectory": { "$ref": "#/$defs/URI", - "description": "Optional per-chat working directory.\n\nIf absent, the chat inherits\n{@link SessionSummary.workingDirectory | the session's working directory}.\nSee {@link ChatState.workingDirectory} for usage notes." + "description": "The chat's primary working directory.\nSee {@link ChatState.primaryWorkingDirectory} for the full semantics." } }, "description": "Mutable summary fields that changed; omitted fields are unchanged.\n\nIdentity fields (`resource`) never change and MUST be omitted by\nsenders; receivers SHOULD ignore them if present." @@ -7387,6 +7461,40 @@ "clientId" ] }, + "SessionWorkingDirectorySetAction": { + "type": "object", + "description": "A working directory was added to the session's\n{@link SessionState.workingDirectories} set.\n\nMembership semantics keyed by the directory URI: the reducer appends\n`directory` when the set does not already contain it (creating the set if\nabsent) and is a no-op when it is already present. Only valid when the agent\nadvertises {@link AgentCapabilities.multipleWorkingDirectories}.", + "properties": { + "type": { + "const": "session/workingDirectorySet" + }, + "directory": { + "$ref": "#/$defs/URI", + "description": "The working directory to grant the session's agent tool access to." + } + }, + "required": [ + "type", + "directory" + ] + }, + "SessionWorkingDirectoryRemovedAction": { + "type": "object", + "description": "A working directory was removed from the session's\n{@link SessionState.workingDirectories} set.\n\nRemoves `directory` from the set; a no-op when it is not present. There is no\natomic backend \"remove one\" primitive — a host reconfigures its agent to the\nreduced set — so this action is safe to model as idempotent. A host MAY\ndecline to apply the removal (e.g. a directory still designated as some\nchat's {@link ChatState.primaryWorkingDirectory | primary}); it then leaves\nthe set unchanged.", + "properties": { + "type": { + "const": "session/workingDirectoryRemoved" + }, + "directory": { + "$ref": "#/$defs/URI", + "description": "The working directory to revoke the session's agent tool access to." + } + }, + "required": [ + "type", + "directory" + ] + }, "SessionInputNeededSetAction": { "type": "object", "description": "A session-level input request was added or updated.\n\nUpsert semantics keyed by {@link SessionInputRequest.id | `request.id`}: the\nhost dispatches this with the full {@link SessionInputRequest} to append a new\nentry to {@link SessionState.inputNeeded} or replace the existing entry with\nthe same `id`.\n\nServer-originated: the host mirrors chat-level requests (elicitations, tool\nconfirmations, client-tool executions) into the session aggregate so clients\nsubscribed only to the session channel can discover them. Clients respond by\ndispatching the ordinary `chat/*` action to the entry's `chat` channel — see\n{@link SessionInputRequest}.", @@ -8190,6 +8298,40 @@ "type" ] }, + "ChatWorkingDirectorySetAction": { + "type": "object", + "description": "A working directory was added to this chat's\n{@link ChatState.workingDirectories} subset.\n\nMembership semantics keyed by the directory URI: the reducer appends\n`directory` when the chat's subset does not already contain it (creating the\nsubset if absent) and is a no-op when it is already present. `directory` MUST\nbe one of the owning session's {@link SessionState.workingDirectories}; a host\nMUST reject a directory that is not. Only valid when the agent advertises\n{@link AgentCapabilities.multipleWorkingDirectories}.", + "properties": { + "type": { + "const": "chat/workingDirectorySet" + }, + "directory": { + "$ref": "#/$defs/URI", + "description": "The working directory to add to this chat's subset." + } + }, + "required": [ + "type", + "directory" + ] + }, + "ChatWorkingDirectoryRemovedAction": { + "type": "object", + "description": "A working directory was removed from this chat's\n{@link ChatState.workingDirectories} subset.\n\nRemoves `directory` from the chat's subset; a no-op when it is not present.\nIdempotent, mirroring `session/workingDirectoryRemoved`. Only affects the\nchat's subset — the directory remains in the session's set.", + "properties": { + "type": { + "const": "chat/workingDirectoryRemoved" + }, + "directory": { + "$ref": "#/$defs/URI", + "description": "The working directory to remove from this chat's subset." + } + }, + "required": [ + "type", + "directory" + ] + }, "ChatUsageAction": { "type": "object", "description": "Token usage report for a turn.", diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index 96351393b..2319bd5c5 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -98,9 +98,12 @@ "$ref": "#/$defs/ProjectInfo", "description": "Server-owned project for this session" }, - "workingDirectory": { - "$ref": "#/$defs/URI", - "description": "The default working directory URI for this session. Individual chats\nMAY override via {@link ChatSummary.workingDirectory | their own\n`workingDirectory`}; this field acts as the fallback for any chat that\ndoes not." + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "The working directories the session's agent has tool access to, as\nmaintained by the `session/workingDirectorySet` /\n`session/workingDirectoryRemoved` actions. Directories are **equal peers** —\nthe session has no primary. Individual chats MAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`} and\ndesignate one of their own directories as primary (see\n{@link ChatState.primaryWorkingDirectory}); a chat that sets no subset\noperates against this full set." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -787,6 +790,10 @@ "multipleChats": { "$ref": "#/$defs/MultipleChatsCapability", "description": "The agent can host more than one concurrent chat per session. When absent,\nclients MUST NOT call `createChat` to open chats beyond the default one the\nsession starts with. An empty object `{}` advertises multi-chat without\nforking; set {@link MultipleChatsCapability.fork} to also allow forking." + }, + "multipleWorkingDirectories": { + "$ref": "#/$defs/MultipleWorkingDirectoriesCapability", + "description": "The session's agent can be granted tool access to more than one working\ndirectory. The directories are treated as equal peers except where the\nagent advertises {@link MultipleWorkingDirectoriesCapability.requiresPrimary}\n(some backends need one directory designated as a primary root).\n\nWhen absent, clients MUST NOT mutate a session's or chat's working-directory\nset and MUST NOT set more than one entry in\n{@link CreateSessionParams.workingDirectories}." } } }, @@ -800,6 +807,16 @@ } } }, + "MultipleWorkingDirectoriesCapability": { + "type": "object", + "description": "Options for the {@link AgentCapabilities.multipleWorkingDirectories} capability.", + "properties": { + "requiresPrimary": { + "type": "boolean", + "description": "The agent requires each chat to designate one of its working directories as\nthe **primary** — a distinguished root the chat is centered on (e.g. the\nagent's process root for that chat, the default location for relative\npaths). Primary is a **per-chat** notion, fixed at chat creation. When\n`true`, a client SHOULD supply {@link CreateChatParams.primaryWorkingDirectory}\n(and {@link CreateSessionParams.primaryWorkingDirectory}, which seeds the\nsession's default chat); a host MAY reject creation that omits it, or fall\nback to the first entry of the chat's working directories. The chosen\nprimary is reported (read-only) on {@link ChatState.primaryWorkingDirectory}.\n\nWhen absent or `false`, the agent has no primary — all directories are\nequal peers and clients need not designate one." + } + } + }, "SessionModelInfo": { "type": "object", "properties": { @@ -914,9 +931,12 @@ "$ref": "#/$defs/ProjectInfo", "description": "Server-owned project for this session" }, - "workingDirectory": { - "$ref": "#/$defs/URI", - "description": "The default working directory URI for this session. Individual chats\nMAY override via {@link ChatSummary.workingDirectory | their own\n`workingDirectory`}; this field acts as the fallback for any chat that\ndoes not." + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "The working directories the session's agent has tool access to, as\nmaintained by the `session/workingDirectorySet` /\n`session/workingDirectoryRemoved` actions. Directories are **equal peers** —\nthe session has no primary. Individual chats MAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`} and\ndesignate one of their own directories as primary (see\n{@link ChatState.primaryWorkingDirectory}); a chat that sets no subset\noperates against this full set." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -953,9 +973,12 @@ "$ref": "#/$defs/ProjectInfo", "description": "Server-owned project for this session" }, - "workingDirectory": { - "$ref": "#/$defs/URI", - "description": "The default working directory URI for this session. Individual chats\nMAY override via {@link ChatSummary.workingDirectory | their own\n`workingDirectory`}; this field acts as the fallback for any chat that\ndoes not." + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "The working directories the session's agent has tool access to, as\nmaintained by the `session/workingDirectorySet` /\n`session/workingDirectoryRemoved` actions. Directories are **equal peers** —\nthe session has no primary. Individual chats MAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`} and\ndesignate one of their own directories as primary (see\n{@link ChatState.primaryWorkingDirectory}); a chat that sets no subset\noperates against this full set." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -1022,7 +1045,7 @@ "_meta": { "type": "object", "additionalProperties": {}, - "description": "Additional provider-specific metadata for this session.\n\nClients MAY look for well-known keys here to provide enhanced UI.\nFor example, a `git` key may provide extra git metadata about the session's\nworkingDirectory." + "description": "Additional provider-specific metadata for this session.\n\nClients MAY look for well-known keys here to provide enhanced UI.\nFor example, a `git` key may provide extra git metadata about the session's\nworking directories." } }, "required": [ @@ -1232,7 +1255,7 @@ }, "SessionSummary": { "type": "object", - "description": "Lightweight catalog entry summarizing one session. Surfaced via\n{@link RootChannelCommands.listSessions | `root/listSessions`} and\n`root/sessionAdded`/`root/sessionSummaryChanged` notifications.\n\n**Aggregation across chats.** Once a session contains more than one chat,\nseveral `SessionSummary` fields are derived from the underlying\n{@link SessionState.chats | chat catalog}. Producers SHOULD follow these\nrules so clients that only consume the session summary (e.g. a session\nlist) still see meaningful state:\n\n- `status`: take the activity bits (`Idle` / `InProgress` / `InputNeeded` /\n `Error` — bits 0–4) from the\n {@link SessionState.defaultChat | default chat} when present, else from\n the most recently modified chat. **Promote** `InputNeeded` whenever any\n chat in the session needs input, and **promote** `Error` whenever any\n chat is in an error state — both override the default-chat bits. The\n orthogonal flag bits (`IsRead`, `IsArchived`) remain session-scoped.\n- `activity`: mirror the activity string of the default chat, or of the\n chat currently driving the promoted status bits when a non-default chat\n wins (e.g. the chat that raised `InputNeeded`).\n- `modifiedAt`: the max of all chats' `modifiedAt`.\n- `workingDirectory`: the session-level **default**. Individual chats MAY\n override via {@link ChatSummary.workingDirectory}; aggregating these up\n is meaningless and SHOULD NOT be attempted.\n- `changes`: optional roll-up across all chats. Producers MAY sum the\n per-chat changeset stats or report the most expensive chat's stats —\n whichever is cheaper for the host to compute.\n\nSessions with a single chat trivially satisfy all of the above (the chat's\nvalues pass through unchanged). The rules only matter once a session\ncarries multiple chats.", + "description": "Lightweight catalog entry summarizing one session. Surfaced via\n{@link RootChannelCommands.listSessions | `root/listSessions`} and\n`root/sessionAdded`/`root/sessionSummaryChanged` notifications.\n\n**Aggregation across chats.** Once a session contains more than one chat,\nseveral `SessionSummary` fields are derived from the underlying\n{@link SessionState.chats | chat catalog}. Producers SHOULD follow these\nrules so clients that only consume the session summary (e.g. a session\nlist) still see meaningful state:\n\n- `status`: take the activity bits (`Idle` / `InProgress` / `InputNeeded` /\n `Error` — bits 0–4) from the\n {@link SessionState.defaultChat | default chat} when present, else from\n the most recently modified chat. **Promote** `InputNeeded` whenever any\n chat in the session needs input, and **promote** `Error` whenever any\n chat is in an error state — both override the default-chat bits. The\n orthogonal flag bits (`IsRead`, `IsArchived`) remain session-scoped.\n- `activity`: mirror the activity string of the default chat, or of the\n chat currently driving the promoted status bits when a non-default chat\n wins (e.g. the chat that raised `InputNeeded`).\n- `modifiedAt`: the max of all chats' `modifiedAt`.\n- `workingDirectories`: the session-level set. Individual chats MAY restrict\n to a subset via {@link ChatSummary.workingDirectories}; aggregating these\n up is meaningless and SHOULD NOT be attempted.\n- `changes`: optional roll-up across all chats. Producers MAY sum the\n per-chat changeset stats or report the most expensive chat's stats —\n whichever is cheaper for the host to compute.\n\nSessions with a single chat trivially satisfy all of the above (the chat's\nvalues pass through unchanged). The rules only matter once a session\ncarries multiple chats.", "properties": { "provider": { "type": "string", @@ -1254,9 +1277,12 @@ "$ref": "#/$defs/ProjectInfo", "description": "Server-owned project for this session" }, - "workingDirectory": { - "$ref": "#/$defs/URI", - "description": "The default working directory URI for this session. Individual chats\nMAY override via {@link ChatSummary.workingDirectory | their own\n`workingDirectory`}; this field acts as the fallback for any chat that\ndoes not." + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "The working directories the session's agent has tool access to, as\nmaintained by the `session/workingDirectorySet` /\n`session/workingDirectoryRemoved` actions. Directories are **equal peers** —\nthe session has no primary. Individual chats MAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`} and\ndesignate one of their own directories as primary (see\n{@link ChatState.primaryWorkingDirectory}); a chat that sets no subset\noperates against this full set." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -2536,9 +2562,16 @@ "$ref": "#/$defs/ChatInteractivity", "description": "How the user can interact with this chat. See {@link ChatInteractivity}.\n\nSupports agent-team patterns where worker chats are read-only or hidden.\nAbsence defaults to {@link ChatInteractivity.Full} for backward\ncompatibility." }, - "workingDirectory": { + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "The subset of the session's\n{@link SessionState.workingDirectories | `workingDirectories`} that this\nchat's agent has tool access to. Every entry MUST be present in the owning\nsession's `workingDirectories`; servers MUST reject a\n`chat/workingDirectorySet` action that violates this constraint.\n\nWhen absent, the chat inherits the full session set. When present but empty\n(not recommended), the chat has no working-directory tool access at all.\n\nDispatch `chat/workingDirectorySet` / `chat/workingDirectoryRemoved` to\nupdate the subset on a running chat." + }, + "primaryWorkingDirectory": { "$ref": "#/$defs/URI", - "description": "Optional per-chat working directory.\n\nIf absent, the chat inherits\n{@link SessionState.workingDirectory | the session's working directory}.\nHosts MAY override this for individual chats — for example, to give a\nsubordinate chat its own git worktree so multiple chats in a session can\nmake independent edits that the orchestrator later merges back." + "description": "The chat's primary working directory — the distinguished root this chat is\ncentered on (e.g. the agent's process root for this chat, the default\nlocation for relative paths). MUST be one of this chat's effective working\ndirectories ({@link workingDirectories}, or the session's set when that is\nabsent). Present when the agent advertises\n{@link MultipleWorkingDirectoriesCapability.requiresPrimary}.\n\n**Read-only and fixed at creation.** It is set from\n{@link CreateChatParams.primaryWorkingDirectory} (or, for the session's\ndefault chat, {@link CreateSessionParams.primaryWorkingDirectory}) and does\nnot change over the chat's lifetime — there is no action to mutate it, and\nit does not participate in `session/chatUpdated`." }, "turns": { "type": "array", @@ -2616,9 +2649,16 @@ "$ref": "#/$defs/ChatInteractivity", "description": "How the user can interact with this chat. See {@link ChatInteractivity}.\n\nSupports agent-team patterns where worker chats are read-only or hidden.\nAbsence defaults to {@link ChatInteractivity.Full} for backward\ncompatibility." }, - "workingDirectory": { + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "The subset of the session's working directories this chat uses.\nSee {@link ChatState.workingDirectories} for the full semantics." + }, + "primaryWorkingDirectory": { "$ref": "#/$defs/URI", - "description": "Optional per-chat working directory.\n\nIf absent, the chat inherits\n{@link SessionSummary.workingDirectory | the session's working directory}.\nSee {@link ChatState.workingDirectory} for usage notes." + "description": "The chat's primary working directory.\nSee {@link ChatState.primaryWorkingDirectory} for the full semantics." } }, "required": [ diff --git a/schema/state.schema.json b/schema/state.schema.json index 7ff9dcb31..f82a33c76 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -538,6 +538,10 @@ "multipleChats": { "$ref": "#/$defs/MultipleChatsCapability", "description": "The agent can host more than one concurrent chat per session. When absent,\nclients MUST NOT call `createChat` to open chats beyond the default one the\nsession starts with. An empty object `{}` advertises multi-chat without\nforking; set {@link MultipleChatsCapability.fork} to also allow forking." + }, + "multipleWorkingDirectories": { + "$ref": "#/$defs/MultipleWorkingDirectoriesCapability", + "description": "The session's agent can be granted tool access to more than one working\ndirectory. The directories are treated as equal peers except where the\nagent advertises {@link MultipleWorkingDirectoriesCapability.requiresPrimary}\n(some backends need one directory designated as a primary root).\n\nWhen absent, clients MUST NOT mutate a session's or chat's working-directory\nset and MUST NOT set more than one entry in\n{@link CreateSessionParams.workingDirectories}." } } }, @@ -551,6 +555,16 @@ } } }, + "MultipleWorkingDirectoriesCapability": { + "type": "object", + "description": "Options for the {@link AgentCapabilities.multipleWorkingDirectories} capability.", + "properties": { + "requiresPrimary": { + "type": "boolean", + "description": "The agent requires each chat to designate one of its working directories as\nthe **primary** — a distinguished root the chat is centered on (e.g. the\nagent's process root for that chat, the default location for relative\npaths). Primary is a **per-chat** notion, fixed at chat creation. When\n`true`, a client SHOULD supply {@link CreateChatParams.primaryWorkingDirectory}\n(and {@link CreateSessionParams.primaryWorkingDirectory}, which seeds the\nsession's default chat); a host MAY reject creation that omits it, or fall\nback to the first entry of the chat's working directories. The chosen\nprimary is reported (read-only) on {@link ChatState.primaryWorkingDirectory}.\n\nWhen absent or `false`, the agent has no primary — all directories are\nequal peers and clients need not designate one." + } + } + }, "SessionModelInfo": { "type": "object", "properties": { @@ -665,9 +679,12 @@ "$ref": "#/$defs/ProjectInfo", "description": "Server-owned project for this session" }, - "workingDirectory": { - "$ref": "#/$defs/URI", - "description": "The default working directory URI for this session. Individual chats\nMAY override via {@link ChatSummary.workingDirectory | their own\n`workingDirectory`}; this field acts as the fallback for any chat that\ndoes not." + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "The working directories the session's agent has tool access to, as\nmaintained by the `session/workingDirectorySet` /\n`session/workingDirectoryRemoved` actions. Directories are **equal peers** —\nthe session has no primary. Individual chats MAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`} and\ndesignate one of their own directories as primary (see\n{@link ChatState.primaryWorkingDirectory}); a chat that sets no subset\noperates against this full set." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -704,9 +721,12 @@ "$ref": "#/$defs/ProjectInfo", "description": "Server-owned project for this session" }, - "workingDirectory": { - "$ref": "#/$defs/URI", - "description": "The default working directory URI for this session. Individual chats\nMAY override via {@link ChatSummary.workingDirectory | their own\n`workingDirectory`}; this field acts as the fallback for any chat that\ndoes not." + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "The working directories the session's agent has tool access to, as\nmaintained by the `session/workingDirectorySet` /\n`session/workingDirectoryRemoved` actions. Directories are **equal peers** —\nthe session has no primary. Individual chats MAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`} and\ndesignate one of their own directories as primary (see\n{@link ChatState.primaryWorkingDirectory}); a chat that sets no subset\noperates against this full set." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -773,7 +793,7 @@ "_meta": { "type": "object", "additionalProperties": {}, - "description": "Additional provider-specific metadata for this session.\n\nClients MAY look for well-known keys here to provide enhanced UI.\nFor example, a `git` key may provide extra git metadata about the session's\nworkingDirectory." + "description": "Additional provider-specific metadata for this session.\n\nClients MAY look for well-known keys here to provide enhanced UI.\nFor example, a `git` key may provide extra git metadata about the session's\nworking directories." } }, "required": [ @@ -983,7 +1003,7 @@ }, "SessionSummary": { "type": "object", - "description": "Lightweight catalog entry summarizing one session. Surfaced via\n{@link RootChannelCommands.listSessions | `root/listSessions`} and\n`root/sessionAdded`/`root/sessionSummaryChanged` notifications.\n\n**Aggregation across chats.** Once a session contains more than one chat,\nseveral `SessionSummary` fields are derived from the underlying\n{@link SessionState.chats | chat catalog}. Producers SHOULD follow these\nrules so clients that only consume the session summary (e.g. a session\nlist) still see meaningful state:\n\n- `status`: take the activity bits (`Idle` / `InProgress` / `InputNeeded` /\n `Error` — bits 0–4) from the\n {@link SessionState.defaultChat | default chat} when present, else from\n the most recently modified chat. **Promote** `InputNeeded` whenever any\n chat in the session needs input, and **promote** `Error` whenever any\n chat is in an error state — both override the default-chat bits. The\n orthogonal flag bits (`IsRead`, `IsArchived`) remain session-scoped.\n- `activity`: mirror the activity string of the default chat, or of the\n chat currently driving the promoted status bits when a non-default chat\n wins (e.g. the chat that raised `InputNeeded`).\n- `modifiedAt`: the max of all chats' `modifiedAt`.\n- `workingDirectory`: the session-level **default**. Individual chats MAY\n override via {@link ChatSummary.workingDirectory}; aggregating these up\n is meaningless and SHOULD NOT be attempted.\n- `changes`: optional roll-up across all chats. Producers MAY sum the\n per-chat changeset stats or report the most expensive chat's stats —\n whichever is cheaper for the host to compute.\n\nSessions with a single chat trivially satisfy all of the above (the chat's\nvalues pass through unchanged). The rules only matter once a session\ncarries multiple chats.", + "description": "Lightweight catalog entry summarizing one session. Surfaced via\n{@link RootChannelCommands.listSessions | `root/listSessions`} and\n`root/sessionAdded`/`root/sessionSummaryChanged` notifications.\n\n**Aggregation across chats.** Once a session contains more than one chat,\nseveral `SessionSummary` fields are derived from the underlying\n{@link SessionState.chats | chat catalog}. Producers SHOULD follow these\nrules so clients that only consume the session summary (e.g. a session\nlist) still see meaningful state:\n\n- `status`: take the activity bits (`Idle` / `InProgress` / `InputNeeded` /\n `Error` — bits 0–4) from the\n {@link SessionState.defaultChat | default chat} when present, else from\n the most recently modified chat. **Promote** `InputNeeded` whenever any\n chat in the session needs input, and **promote** `Error` whenever any\n chat is in an error state — both override the default-chat bits. The\n orthogonal flag bits (`IsRead`, `IsArchived`) remain session-scoped.\n- `activity`: mirror the activity string of the default chat, or of the\n chat currently driving the promoted status bits when a non-default chat\n wins (e.g. the chat that raised `InputNeeded`).\n- `modifiedAt`: the max of all chats' `modifiedAt`.\n- `workingDirectories`: the session-level set. Individual chats MAY restrict\n to a subset via {@link ChatSummary.workingDirectories}; aggregating these\n up is meaningless and SHOULD NOT be attempted.\n- `changes`: optional roll-up across all chats. Producers MAY sum the\n per-chat changeset stats or report the most expensive chat's stats —\n whichever is cheaper for the host to compute.\n\nSessions with a single chat trivially satisfy all of the above (the chat's\nvalues pass through unchanged). The rules only matter once a session\ncarries multiple chats.", "properties": { "provider": { "type": "string", @@ -1005,9 +1025,12 @@ "$ref": "#/$defs/ProjectInfo", "description": "Server-owned project for this session" }, - "workingDirectory": { - "$ref": "#/$defs/URI", - "description": "The default working directory URI for this session. Individual chats\nMAY override via {@link ChatSummary.workingDirectory | their own\n`workingDirectory`}; this field acts as the fallback for any chat that\ndoes not." + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "The working directories the session's agent has tool access to, as\nmaintained by the `session/workingDirectorySet` /\n`session/workingDirectoryRemoved` actions. Directories are **equal peers** —\nthe session has no primary. Individual chats MAY restrict to a subset via\n{@link ChatSummary.workingDirectories | their own `workingDirectories`} and\ndesignate one of their own directories as primary (see\n{@link ChatState.primaryWorkingDirectory}); a chat that sets no subset\noperates against this full set." }, "annotations": { "$ref": "#/$defs/AnnotationsSummary", @@ -2287,9 +2310,16 @@ "$ref": "#/$defs/ChatInteractivity", "description": "How the user can interact with this chat. See {@link ChatInteractivity}.\n\nSupports agent-team patterns where worker chats are read-only or hidden.\nAbsence defaults to {@link ChatInteractivity.Full} for backward\ncompatibility." }, - "workingDirectory": { + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "The subset of the session's\n{@link SessionState.workingDirectories | `workingDirectories`} that this\nchat's agent has tool access to. Every entry MUST be present in the owning\nsession's `workingDirectories`; servers MUST reject a\n`chat/workingDirectorySet` action that violates this constraint.\n\nWhen absent, the chat inherits the full session set. When present but empty\n(not recommended), the chat has no working-directory tool access at all.\n\nDispatch `chat/workingDirectorySet` / `chat/workingDirectoryRemoved` to\nupdate the subset on a running chat." + }, + "primaryWorkingDirectory": { "$ref": "#/$defs/URI", - "description": "Optional per-chat working directory.\n\nIf absent, the chat inherits\n{@link SessionState.workingDirectory | the session's working directory}.\nHosts MAY override this for individual chats — for example, to give a\nsubordinate chat its own git worktree so multiple chats in a session can\nmake independent edits that the orchestrator later merges back." + "description": "The chat's primary working directory — the distinguished root this chat is\ncentered on (e.g. the agent's process root for this chat, the default\nlocation for relative paths). MUST be one of this chat's effective working\ndirectories ({@link workingDirectories}, or the session's set when that is\nabsent). Present when the agent advertises\n{@link MultipleWorkingDirectoriesCapability.requiresPrimary}.\n\n**Read-only and fixed at creation.** It is set from\n{@link CreateChatParams.primaryWorkingDirectory} (or, for the session's\ndefault chat, {@link CreateSessionParams.primaryWorkingDirectory}) and does\nnot change over the chat's lifetime — there is no action to mutate it, and\nit does not participate in `session/chatUpdated`." }, "turns": { "type": "array", @@ -2367,9 +2397,16 @@ "$ref": "#/$defs/ChatInteractivity", "description": "How the user can interact with this chat. See {@link ChatInteractivity}.\n\nSupports agent-team patterns where worker chats are read-only or hidden.\nAbsence defaults to {@link ChatInteractivity.Full} for backward\ncompatibility." }, - "workingDirectory": { + "workingDirectories": { + "type": "array", + "items": { + "$ref": "#/$defs/URI" + }, + "description": "The subset of the session's working directories this chat uses.\nSee {@link ChatState.workingDirectories} for the full semantics." + }, + "primaryWorkingDirectory": { "$ref": "#/$defs/URI", - "description": "Optional per-chat working directory.\n\nIf absent, the chat inherits\n{@link SessionSummary.workingDirectory | the session's working directory}.\nSee {@link ChatState.workingDirectory} for usage notes." + "description": "The chat's primary working directory.\nSee {@link ChatState.primaryWorkingDirectory} for the full semantics." } }, "required": [ diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index 02fa0334c..7a5c0e26a 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -661,6 +661,7 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; goName?: strin { name: 'AgentInfo' }, { name: 'AgentCapabilities' }, { name: 'MultipleChatsCapability' }, + { name: 'MultipleWorkingDirectoriesCapability' }, { name: 'SessionModelInfo' }, { name: 'ModelSelection' }, { name: 'AgentSelection' }, @@ -1311,6 +1312,10 @@ const ACTION_VARIANTS: { { type: 'session/serverToolsChanged', variantName: 'SessionServerToolsChanged', tsInterface: 'SessionServerToolsChangedAction' }, { type: 'session/activeClientSet', variantName: 'SessionActiveClientSet', tsInterface: 'SessionActiveClientSetAction' }, { type: 'session/activeClientRemoved', variantName: 'SessionActiveClientRemoved', tsInterface: 'SessionActiveClientRemovedAction' }, + { type: 'session/workingDirectorySet', variantName: 'SessionWorkingDirectorySet', tsInterface: 'SessionWorkingDirectorySetAction' }, + { type: 'session/workingDirectoryRemoved', variantName: 'SessionWorkingDirectoryRemoved', tsInterface: 'SessionWorkingDirectoryRemovedAction' }, + { type: 'chat/workingDirectorySet', variantName: 'ChatWorkingDirectorySet', tsInterface: 'ChatWorkingDirectorySetAction' }, + { type: 'chat/workingDirectoryRemoved', variantName: 'ChatWorkingDirectoryRemoved', tsInterface: 'ChatWorkingDirectoryRemovedAction' }, { type: 'session/inputNeededSet', variantName: 'SessionInputNeededSet', tsInterface: 'SessionInputNeededSetAction' }, { type: 'session/inputNeededRemoved', variantName: 'SessionInputNeededRemoved', tsInterface: 'SessionInputNeededRemovedAction' }, { type: 'session/customizationsChanged', variantName: 'SessionCustomizationsChanged', tsInterface: 'SessionCustomizationsChangedAction' }, diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index 49468b017..2cf0cd87d 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -804,6 +804,7 @@ const STATE_STRUCTS = [ 'Icon', 'ProtectedResourceMetadata', 'RootState', 'RootConfigState', 'AgentInfo', 'AgentCapabilities', 'MultipleChatsCapability', + 'MultipleWorkingDirectoriesCapability', 'SessionModelInfo', 'ModelSelection', 'AgentSelection', 'ConfigPropertySchema', 'ConfigSchema', 'PendingMessage', 'ChatState', 'ChatSummary', 'SessionState', 'SessionActiveClient', 'SessionChatInputRequest', 'SessionToolConfirmationRequest', 'SessionToolClientExecutionRequest', @@ -1219,6 +1220,10 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'session/serverToolsChanged', caseName: 'SessionServerToolsChanged', tsInterface: 'SessionServerToolsChangedAction' }, { type: 'session/activeClientSet', caseName: 'SessionActiveClientSet', tsInterface: 'SessionActiveClientSetAction' }, { type: 'session/activeClientRemoved', caseName: 'SessionActiveClientRemoved', tsInterface: 'SessionActiveClientRemovedAction' }, + { type: 'session/workingDirectorySet', caseName: 'SessionWorkingDirectorySet', tsInterface: 'SessionWorkingDirectorySetAction' }, + { type: 'session/workingDirectoryRemoved', caseName: 'SessionWorkingDirectoryRemoved', tsInterface: 'SessionWorkingDirectoryRemovedAction' }, + { type: 'chat/workingDirectorySet', caseName: 'ChatWorkingDirectorySet', tsInterface: 'ChatWorkingDirectorySetAction' }, + { type: 'chat/workingDirectoryRemoved', caseName: 'ChatWorkingDirectoryRemoved', tsInterface: 'ChatWorkingDirectoryRemovedAction' }, { type: 'session/inputNeededSet', caseName: 'SessionInputNeededSet', tsInterface: 'SessionInputNeededSetAction' }, { type: 'session/inputNeededRemoved', caseName: 'SessionInputNeededRemoved', tsInterface: 'SessionInputNeededRemovedAction' }, { type: 'chat/pendingMessageSet', caseName: 'ChatPendingMessageSet', tsInterface: 'ChatPendingMessageSetAction' }, diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index 4773ddb55..94d41c48d 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -692,6 +692,7 @@ const STATE_STRUCTS: { name: string; omitDiscriminants?: boolean; rustName?: str { name: 'AgentInfo' }, { name: 'AgentCapabilities' }, { name: 'MultipleChatsCapability' }, + { name: 'MultipleWorkingDirectoriesCapability' }, { name: 'SessionModelInfo' }, { name: 'ModelSelection' }, { name: 'AgentSelection' }, @@ -1215,6 +1216,10 @@ const ACTION_VARIANTS: { { type: 'session/serverToolsChanged', variantName: 'SessionServerToolsChanged', tsInterface: 'SessionServerToolsChangedAction' }, { type: 'session/activeClientSet', variantName: 'SessionActiveClientSet', tsInterface: 'SessionActiveClientSetAction' }, { type: 'session/activeClientRemoved', variantName: 'SessionActiveClientRemoved', tsInterface: 'SessionActiveClientRemovedAction' }, + { type: 'session/workingDirectorySet', variantName: 'SessionWorkingDirectorySet', tsInterface: 'SessionWorkingDirectorySetAction' }, + { type: 'session/workingDirectoryRemoved', variantName: 'SessionWorkingDirectoryRemoved', tsInterface: 'SessionWorkingDirectoryRemovedAction' }, + { type: 'chat/workingDirectorySet', variantName: 'ChatWorkingDirectorySet', tsInterface: 'ChatWorkingDirectorySetAction' }, + { type: 'chat/workingDirectoryRemoved', variantName: 'ChatWorkingDirectoryRemoved', tsInterface: 'ChatWorkingDirectoryRemovedAction' }, { type: 'session/inputNeededSet', variantName: 'SessionInputNeededSet', tsInterface: 'SessionInputNeededSetAction', boxed: true }, { type: 'session/inputNeededRemoved', variantName: 'SessionInputNeededRemoved', tsInterface: 'SessionInputNeededRemovedAction' }, { type: 'chat/pendingMessageSet', variantName: 'ChatPendingMessageSet', tsInterface: 'ChatPendingMessageSetAction' }, diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index f86be48c0..649be8494 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -554,6 +554,7 @@ const STATE_STRUCTS = [ 'Icon', 'ProtectedResourceMetadata', 'RootState', 'RootConfigState', 'AgentInfo', 'AgentCapabilities', 'MultipleChatsCapability', + 'MultipleWorkingDirectoriesCapability', 'SessionModelInfo', 'ModelSelection', 'AgentSelection', 'ConfigPropertySchema', 'ConfigSchema', 'PendingMessage', 'ChatState', 'ChatSummary', 'SessionState', 'SessionActiveClient', 'SessionChatInputRequest', 'SessionToolConfirmationRequest', 'SessionToolClientExecutionRequest', @@ -1128,6 +1129,10 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[] { type: 'session/serverToolsChanged', caseName: 'sessionServerToolsChanged', tsInterface: 'SessionServerToolsChangedAction' }, { type: 'session/activeClientSet', caseName: 'sessionActiveClientSet', tsInterface: 'SessionActiveClientSetAction' }, { type: 'session/activeClientRemoved', caseName: 'sessionActiveClientRemoved', tsInterface: 'SessionActiveClientRemovedAction' }, + { type: 'session/workingDirectorySet', caseName: 'sessionWorkingDirectorySet', tsInterface: 'SessionWorkingDirectorySetAction' }, + { type: 'session/workingDirectoryRemoved', caseName: 'sessionWorkingDirectoryRemoved', tsInterface: 'SessionWorkingDirectoryRemovedAction' }, + { type: 'chat/workingDirectorySet', caseName: 'chatWorkingDirectorySet', tsInterface: 'ChatWorkingDirectorySetAction' }, + { type: 'chat/workingDirectoryRemoved', caseName: 'chatWorkingDirectoryRemoved', tsInterface: 'ChatWorkingDirectoryRemovedAction' }, { type: 'session/inputNeededSet', caseName: 'sessionInputNeededSet', tsInterface: 'SessionInputNeededSetAction' }, { type: 'session/inputNeededRemoved', caseName: 'sessionInputNeededRemoved', tsInterface: 'SessionInputNeededRemovedAction' }, { type: 'chat/pendingMessageSet', caseName: 'chatPendingMessageSet', tsInterface: 'ChatPendingMessageSetAction' }, diff --git a/types/action-origin.generated.ts b/types/action-origin.generated.ts index fea450a65..92cffe482 100644 --- a/types/action-origin.generated.ts +++ b/types/action-origin.generated.ts @@ -17,6 +17,8 @@ import type { SessionServerToolsChangedAction, SessionActiveClientSetAction, SessionActiveClientRemovedAction, + SessionWorkingDirectorySetAction, + SessionWorkingDirectoryRemovedAction, SessionInputNeededSetAction, SessionInputNeededRemovedAction, SessionCustomizationsChangedAction, @@ -48,6 +50,8 @@ import type { ChatTurnCancelledAction, ChatErrorAction, ChatActivityChangedAction, + ChatWorkingDirectorySetAction, + ChatWorkingDirectoryRemovedAction, ChatUsageAction, ChatReasoningAction, ChatPendingMessageSetAction, @@ -122,6 +126,8 @@ export type SessionAction = | SessionServerToolsChangedAction | SessionActiveClientSetAction | SessionActiveClientRemovedAction + | SessionWorkingDirectorySetAction + | SessionWorkingDirectoryRemovedAction | SessionInputNeededSetAction | SessionInputNeededRemovedAction | SessionCustomizationsChangedAction @@ -144,6 +150,8 @@ export type ClientSessionAction = | SessionTitleChangedAction | SessionActiveClientSetAction | SessionActiveClientRemovedAction + | SessionWorkingDirectorySetAction + | SessionWorkingDirectoryRemovedAction | SessionCustomizationToggledAction | SessionMcpServerStartRequestedAction | SessionMcpServerStopRequestedAction @@ -190,6 +198,8 @@ export type ChatAction = | ChatTurnCancelledAction | ChatErrorAction | ChatActivityChangedAction + | ChatWorkingDirectorySetAction + | ChatWorkingDirectoryRemovedAction | ChatUsageAction | ChatReasoningAction | ChatPendingMessageSetAction @@ -211,6 +221,8 @@ export type ClientChatAction = | ChatToolCallResultConfirmedAction | ChatToolCallContentChangedAction | ChatTurnCancelledAction + | ChatWorkingDirectorySetAction + | ChatWorkingDirectoryRemovedAction | ChatPendingMessageSetAction | ChatPendingMessageRemovedAction | ChatQueuedMessagesReorderedAction @@ -359,6 +371,8 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.SessionServerToolsChanged]: false, [ActionType.SessionActiveClientSet]: true, [ActionType.SessionActiveClientRemoved]: true, + [ActionType.SessionWorkingDirectorySet]: true, + [ActionType.SessionWorkingDirectoryRemoved]: true, [ActionType.SessionInputNeededSet]: false, [ActionType.SessionInputNeededRemoved]: false, [ActionType.SessionCustomizationsChanged]: false, @@ -390,6 +404,8 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.ChatTurnCancelled]: true, [ActionType.ChatError]: false, [ActionType.ChatActivityChanged]: false, + [ActionType.ChatWorkingDirectorySet]: true, + [ActionType.ChatWorkingDirectoryRemoved]: true, [ActionType.ChatUsage]: false, [ActionType.ChatReasoning]: false, [ActionType.ChatPendingMessageSet]: true, diff --git a/types/channels-chat/actions.ts b/types/channels-chat/actions.ts index ca2e90496..1e111a854 100644 --- a/types/channels-chat/actions.ts +++ b/types/channels-chat/actions.ts @@ -5,7 +5,7 @@ */ import { ActionType } from '../common/actions.js'; -import type { StringOrMarkdown, ErrorInfo, FileEdit, UsageInfo } from '../common/state.js'; +import type { StringOrMarkdown, ErrorInfo, FileEdit, UsageInfo, URI } from '../common/state.js'; import type { McpAuthRequirement } from '../channels-session/state.js'; import type { Message, @@ -503,6 +503,45 @@ export interface ChatActivityChangedAction { activity?: string; } +/** + * A working directory was added to this chat's + * {@link ChatState.workingDirectories} subset. + * + * Membership semantics keyed by the directory URI: the reducer appends + * `directory` when the chat's subset does not already contain it (creating the + * subset if absent) and is a no-op when it is already present. `directory` MUST + * be one of the owning session's {@link SessionState.workingDirectories}; a host + * MUST reject a directory that is not. Only valid when the agent advertises + * {@link AgentCapabilities.multipleWorkingDirectories}. + * + * @category Chat Actions + * @version 1 + * @clientDispatchable + */ +export interface ChatWorkingDirectorySetAction { + type: ActionType.ChatWorkingDirectorySet; + /** The working directory to add to this chat's subset. */ + directory: URI; +} + +/** + * A working directory was removed from this chat's + * {@link ChatState.workingDirectories} subset. + * + * Removes `directory` from the chat's subset; a no-op when it is not present. + * Idempotent, mirroring `session/workingDirectoryRemoved`. Only affects the + * chat's subset — the directory remains in the session's set. + * + * @category Chat Actions + * @version 1 + * @clientDispatchable + */ +export interface ChatWorkingDirectoryRemovedAction { + type: ActionType.ChatWorkingDirectoryRemoved; + /** The working directory to remove from this chat's subset. */ + directory: URI; +} + /** * Token usage report for a turn. * @@ -766,6 +805,8 @@ export type ChatAction = | ChatTurnCancelledAction | ChatErrorAction | ChatActivityChangedAction + | ChatWorkingDirectorySetAction + | ChatWorkingDirectoryRemovedAction | ChatUsageAction | ChatReasoningAction | ChatTruncatedAction diff --git a/types/channels-chat/commands.ts b/types/channels-chat/commands.ts index e029e2f2d..0e78d31c6 100644 --- a/types/channels-chat/commands.ts +++ b/types/channels-chat/commands.ts @@ -38,6 +38,29 @@ export interface CreateChatParams extends BaseParams { initialMessage?: Message; /** Optional source chat and turn to fork from. */ source?: ChatForkSource; + /** + * Initial working-directory subset for this chat. Every entry MUST be + * present in the owning session's `workingDirectories`; the server MUST + * reject any entry that is not. When absent, the chat inherits the full + * session set. Forked chats (`source`) inherit the source chat's + * `workingDirectories`; this field is ignored for forked chats. + * + * A client MUST NOT supply this field unless the agent advertises + * {@link AgentCapabilities.multipleWorkingDirectories}. + */ + workingDirectories?: URI[]; + /** + * The chat's primary working directory — the distinguished root this chat is + * centered on. When set, it MUST be one of the chat's effective working + * directories ({@link workingDirectories}, or the session's set when that is + * omitted). A client SHOULD supply this when the agent advertises + * {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY + * reject creation that omits it, or fall back to the first of the chat's + * directories. Fixed at creation and reported (read-only) on + * {@link ChatState.primaryWorkingDirectory}. Ignored for forked chats (a fork + * inherits the source chat's primary). + */ + primaryWorkingDirectory?: URI; } // ─── disposeChat ───────────────────────────────────────────────────────────── diff --git a/types/channels-chat/reducer.ts b/types/channels-chat/reducer.ts index 4da0efed6..2e63b9c19 100644 --- a/types/channels-chat/reducer.ts +++ b/types/channels-chat/reducer.ts @@ -382,6 +382,30 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st case ActionType.ChatActivityChanged: return { ...state, activity: action.activity }; + // ── Working Directories ─────────────────────────────────────────────── + + case ActionType.ChatWorkingDirectorySet: { + const list = state.workingDirectories ?? []; + if (list.includes(action.directory)) { + return state; + } + return { ...state, workingDirectories: [...list, action.directory] }; + } + + case ActionType.ChatWorkingDirectoryRemoved: { + const list = state.workingDirectories; + if (!list) { + return state; + } + const idx = list.indexOf(action.directory); + if (idx < 0) { + return state; + } + const updated = list.slice(); + updated.splice(idx, 1); + return { ...state, workingDirectories: updated }; + } + // ── Tool Call State Machine ─────────────────────────────────────────── case ActionType.ChatToolCallStart: diff --git a/types/channels-chat/state.ts b/types/channels-chat/state.ts index 198772533..bfccceed5 100644 --- a/types/channels-chat/state.ts +++ b/types/channels-chat/state.ts @@ -58,15 +58,34 @@ export interface ChatState { */ interactivity?: ChatInteractivity; /** - * Optional per-chat working directory. + * The subset of the session's + * {@link SessionState.workingDirectories | `workingDirectories`} that this + * chat's agent has tool access to. Every entry MUST be present in the owning + * session's `workingDirectories`; servers MUST reject a + * `chat/workingDirectorySet` action that violates this constraint. * - * If absent, the chat inherits - * {@link SessionState.workingDirectory | the session's working directory}. - * Hosts MAY override this for individual chats — for example, to give a - * subordinate chat its own git worktree so multiple chats in a session can - * make independent edits that the orchestrator later merges back. + * When absent, the chat inherits the full session set. When present but empty + * (not recommended), the chat has no working-directory tool access at all. + * + * Dispatch `chat/workingDirectorySet` / `chat/workingDirectoryRemoved` to + * update the subset on a running chat. + */ + workingDirectories?: URI[]; + /** + * The chat's primary working directory — the distinguished root this chat is + * centered on (e.g. the agent's process root for this chat, the default + * location for relative paths). MUST be one of this chat's effective working + * directories ({@link workingDirectories}, or the session's set when that is + * absent). Present when the agent advertises + * {@link MultipleWorkingDirectoriesCapability.requiresPrimary}. + * + * **Read-only and fixed at creation.** It is set from + * {@link CreateChatParams.primaryWorkingDirectory} (or, for the session's + * default chat, {@link CreateSessionParams.primaryWorkingDirectory}) and does + * not change over the chat's lifetime — there is no action to mutate it, and + * it does not participate in `session/chatUpdated`. */ - workingDirectory?: URI; + primaryWorkingDirectory?: URI; // ── Conversation contents ────────────────────────────────────────── /** Completed turns */ @@ -135,13 +154,15 @@ export interface ChatSummary { */ interactivity?: ChatInteractivity; /** - * Optional per-chat working directory. - * - * If absent, the chat inherits - * {@link SessionSummary.workingDirectory | the session's working directory}. - * See {@link ChatState.workingDirectory} for usage notes. + * The subset of the session's working directories this chat uses. + * See {@link ChatState.workingDirectories} for the full semantics. + */ + workingDirectories?: URI[]; + /** + * The chat's primary working directory. + * See {@link ChatState.primaryWorkingDirectory} for the full semantics. */ - workingDirectory?: URI; + primaryWorkingDirectory?: URI; } /** diff --git a/types/channels-root/state.ts b/types/channels-root/state.ts index 08a21371a..92b4fa08e 100644 --- a/types/channels-root/state.ts +++ b/types/channels-root/state.ts @@ -110,6 +110,17 @@ export interface AgentCapabilities { * forking; set {@link MultipleChatsCapability.fork} to also allow forking. */ multipleChats?: MultipleChatsCapability; + /** + * The session's agent can be granted tool access to more than one working + * directory. The directories are treated as equal peers except where the + * agent advertises {@link MultipleWorkingDirectoriesCapability.requiresPrimary} + * (some backends need one directory designated as a primary root). + * + * When absent, clients MUST NOT mutate a session's or chat's working-directory + * set and MUST NOT set more than one entry in + * {@link CreateSessionParams.workingDirectories}. + */ + multipleWorkingDirectories?: MultipleWorkingDirectoriesCapability; } /** @@ -126,6 +137,29 @@ export interface MultipleChatsCapability { fork?: boolean; } +/** + * Options for the {@link AgentCapabilities.multipleWorkingDirectories} capability. + * + * @category Root State + */ +export interface MultipleWorkingDirectoriesCapability { + /** + * The agent requires each chat to designate one of its working directories as + * the **primary** — a distinguished root the chat is centered on (e.g. the + * agent's process root for that chat, the default location for relative + * paths). Primary is a **per-chat** notion, fixed at chat creation. When + * `true`, a client SHOULD supply {@link CreateChatParams.primaryWorkingDirectory} + * (and {@link CreateSessionParams.primaryWorkingDirectory}, which seeds the + * session's default chat); a host MAY reject creation that omits it, or fall + * back to the first entry of the chat's working directories. The chosen + * primary is reported (read-only) on {@link ChatState.primaryWorkingDirectory}. + * + * When absent or `false`, the agent has no primary — all directories are + * equal peers and clients need not designate one. + */ + requiresPrimary?: boolean; +} + /** * @category Root State */ diff --git a/types/channels-session/actions.ts b/types/channels-session/actions.ts index b3d4bc338..14dbf468c 100644 --- a/types/channels-session/actions.ts +++ b/types/channels-session/actions.ts @@ -255,6 +255,48 @@ export interface SessionActiveClientRemovedAction { clientId: string; } +// ─── Working Directory Actions ─────────────────────────────────────────────── + +/** + * A working directory was added to the session's + * {@link SessionState.workingDirectories} set. + * + * Membership semantics keyed by the directory URI: the reducer appends + * `directory` when the set does not already contain it (creating the set if + * absent) and is a no-op when it is already present. Only valid when the agent + * advertises {@link AgentCapabilities.multipleWorkingDirectories}. + * + * @category Session Actions + * @version 1 + * @clientDispatchable + */ +export interface SessionWorkingDirectorySetAction { + type: ActionType.SessionWorkingDirectorySet; + /** The working directory to grant the session's agent tool access to. */ + directory: URI; +} + +/** + * A working directory was removed from the session's + * {@link SessionState.workingDirectories} set. + * + * Removes `directory` from the set; a no-op when it is not present. There is no + * atomic backend "remove one" primitive — a host reconfigures its agent to the + * reduced set — so this action is safe to model as idempotent. A host MAY + * decline to apply the removal (e.g. a directory still designated as some + * chat's {@link ChatState.primaryWorkingDirectory | primary}); it then leaves + * the set unchanged. + * + * @category Session Actions + * @version 1 + * @clientDispatchable + */ +export interface SessionWorkingDirectoryRemovedAction { + type: ActionType.SessionWorkingDirectoryRemoved; + /** The working directory to revoke the session's agent tool access to. */ + directory: URI; +} + // ─── Input Needed Actions ──────────────────────────────────────────────────── /** diff --git a/types/channels-session/commands.ts b/types/channels-session/commands.ts index d6b11eb14..66bc3b0aa 100644 --- a/types/channels-session/commands.ts +++ b/types/channels-session/commands.ts @@ -66,8 +66,45 @@ export interface CreateSessionParams extends BaseParams { channel: URI; /** Agent provider ID */ provider?: string; - /** Working directory for the session */ - workingDirectory?: URI; + /** + * The working directories the session's agent is granted tool access to. + * A session may span multiple directories; they are equal peers except when + * the agent advertises + * {@link MultipleWorkingDirectoriesCapability.requiresPrimary}, in which case + * one of them should be designated the primary via + * {@link primaryWorkingDirectory}. + * + * A client MUST NOT supply more than one entry unless the agent advertises + * {@link AgentCapabilities.multipleWorkingDirectories}; a server without that + * capability treats only the first entry as the session's working directory + * and ignores the rest. Dispatch `session/workingDirectorySet` / + * `session/workingDirectoryRemoved` to change the set after the session has + * started. + * + * Ignored for forked sessions — a fork inherits its working directories + * from the source session identified by `fork`. + */ + workingDirectories?: URI[]; + /** + * The primary working directory for the session's **default chat**. + * + * A session has no primary of its own — primary is a per-chat notion (see + * {@link ChatState.primaryWorkingDirectory}). But `createSession` implicitly + * creates the session's default chat, and there is no separate `createChat` + * call to carry that chat's create-time fields. This field is therefore the + * only place a client can designate the **default chat's** primary at birth; + * it is copied into that chat's read-only `primaryWorkingDirectory`. For any + * non-default chat, pass {@link CreateChatParams.primaryWorkingDirectory} + * instead. + * + * When set, it MUST be one of {@link workingDirectories}. A client SHOULD + * supply this when the agent advertises + * {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY + * reject creation that omits it, or fall back to the first entry of + * `workingDirectories`. Ignored for forked sessions (a fork inherits the + * source session's chats and their primaries). + */ + primaryWorkingDirectory?: URI; /** * Fork from an existing session. The new session is populated with content * from the source session up to and including the specified turn's response. diff --git a/types/channels-session/reducer.ts b/types/channels-session/reducer.ts index 7d1700e6a..3847c9f11 100644 --- a/types/channels-session/reducer.ts +++ b/types/channels-session/reducer.ts @@ -226,6 +226,30 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: return { ...state, activeClients: updated }; } + // ── Working Directories ───────────────────────────────────────────── + + case ActionType.SessionWorkingDirectorySet: { + const list = state.workingDirectories ?? []; + if (list.includes(action.directory)) { + return state; + } + return { ...state, workingDirectories: [...list, action.directory] }; + } + + case ActionType.SessionWorkingDirectoryRemoved: { + const list = state.workingDirectories; + if (!list) { + return state; + } + const idx = list.indexOf(action.directory); + if (idx < 0) { + return state; + } + const updated = list.slice(); + updated.splice(idx, 1); + return { ...state, workingDirectories: updated }; + } + // ── Input Needed ──────────────────────────────────────────────────── case ActionType.SessionInputNeededSet: { diff --git a/types/channels-session/state.ts b/types/channels-session/state.ts index 7039ef808..3d8739290 100644 --- a/types/channels-session/state.ts +++ b/types/channels-session/state.ts @@ -84,12 +84,16 @@ export interface SessionMetadata { /** Server-owned project for this session */ project?: ProjectInfo; /** - * The default working directory URI for this session. Individual chats - * MAY override via {@link ChatSummary.workingDirectory | their own - * `workingDirectory`}; this field acts as the fallback for any chat that - * does not. + * The working directories the session's agent has tool access to, as + * maintained by the `session/workingDirectorySet` / + * `session/workingDirectoryRemoved` actions. Directories are **equal peers** — + * the session has no primary. Individual chats MAY restrict to a subset via + * {@link ChatSummary.workingDirectories | their own `workingDirectories`} and + * designate one of their own directories as primary (see + * {@link ChatState.primaryWorkingDirectory}); a chat that sets no subset + * operates against this full set. */ - workingDirectory?: URI; + workingDirectories?: URI[]; /** * Lightweight summary of this session's inline annotations channel * (`ahp-session://annotations`). Surfaced so badge UI can render @@ -192,7 +196,7 @@ export interface SessionState extends SessionMetadata { * * Clients MAY look for well-known keys here to provide enhanced UI. * For example, a `git` key may provide extra git metadata about the session's - * workingDirectory. + * working directories. */ _meta?: Record; } @@ -417,9 +421,9 @@ export interface ProjectInfo { * chat currently driving the promoted status bits when a non-default chat * wins (e.g. the chat that raised `InputNeeded`). * - `modifiedAt`: the max of all chats' `modifiedAt`. - * - `workingDirectory`: the session-level **default**. Individual chats MAY - * override via {@link ChatSummary.workingDirectory}; aggregating these up - * is meaningless and SHOULD NOT be attempted. + * - `workingDirectories`: the session-level set. Individual chats MAY restrict + * to a subset via {@link ChatSummary.workingDirectories}; aggregating these + * up is meaningless and SHOULD NOT be attempted. * - `changes`: optional roll-up across all chats. Producers MAY sum the * per-chat changeset stats or report the most expensive chat's stats — * whichever is cheaper for the host to compute. diff --git a/types/common/actions.ts b/types/common/actions.ts index a2dcf2d42..d07164129 100644 --- a/types/common/actions.ts +++ b/types/common/actions.ts @@ -26,6 +26,8 @@ import type { SessionServerToolsChangedAction, SessionActiveClientSetAction, SessionActiveClientRemovedAction, + SessionWorkingDirectorySetAction, + SessionWorkingDirectoryRemovedAction, SessionInputNeededSetAction, SessionInputNeededRemovedAction, SessionCustomizationsChangedAction, @@ -60,6 +62,8 @@ import type { ChatTurnCancelledAction, ChatErrorAction, ChatActivityChangedAction, + ChatWorkingDirectorySetAction, + ChatWorkingDirectoryRemovedAction, ChatUsageAction, ChatReasoningAction, ChatPendingMessageSetAction, @@ -142,12 +146,16 @@ export const enum ActionType { ChatTurnCancelled = 'chat/turnCancelled', ChatError = 'chat/error', ChatActivityChanged = 'chat/activityChanged', + ChatWorkingDirectorySet = 'chat/workingDirectorySet', + ChatWorkingDirectoryRemoved = 'chat/workingDirectoryRemoved', SessionTitleChanged = 'session/titleChanged', ChatUsage = 'chat/usage', ChatReasoning = 'chat/reasoning', SessionServerToolsChanged = 'session/serverToolsChanged', SessionActiveClientSet = 'session/activeClientSet', SessionActiveClientRemoved = 'session/activeClientRemoved', + SessionWorkingDirectorySet = 'session/workingDirectorySet', + SessionWorkingDirectoryRemoved = 'session/workingDirectoryRemoved', SessionInputNeededSet = 'session/inputNeededSet', SessionInputNeededRemoved = 'session/inputNeededRemoved', ChatPendingMessageSet = 'chat/pendingMessageSet', @@ -249,6 +257,8 @@ export type StateAction = | SessionServerToolsChangedAction | SessionActiveClientSetAction | SessionActiveClientRemovedAction + | SessionWorkingDirectorySetAction + | SessionWorkingDirectoryRemovedAction | SessionInputNeededSetAction | SessionInputNeededRemovedAction | SessionCustomizationsChangedAction @@ -280,6 +290,8 @@ export type StateAction = | ChatTurnCancelledAction | ChatErrorAction | ChatActivityChangedAction + | ChatWorkingDirectorySetAction + | ChatWorkingDirectoryRemovedAction | ChatUsageAction | ChatReasoningAction | ChatPendingMessageSetAction diff --git a/types/common/commands.ts b/types/common/commands.ts index 8a9914f3e..e8ad5e57f 100644 --- a/types/common/commands.ts +++ b/types/common/commands.ts @@ -277,7 +277,7 @@ export interface InitializeResult { * @method ping * @direction Client → Server * @messageType Request - * @version 0.1.0 + * @version 1 */ export interface PingParams extends BaseParams { channel: 'ahp-root://'; diff --git a/types/test-cases/reducers/241-session-workingdirectoryset-adds-to-empty-set.json b/types/test-cases/reducers/241-session-workingdirectoryset-adds-to-empty-set.json new file mode 100644 index 000000000..a86218b41 --- /dev/null +++ b/types/test-cases/reducers/241-session-workingdirectoryset-adds-to-empty-set.json @@ -0,0 +1,29 @@ +{ + "description": "session-workingDirectorySet adds to empty set", + "reducer": "session", + "initial": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [] + }, + "actions": [ + { + "type": "session/workingDirectorySet", + "directory": "file:///repo-a" + } + ], + "expected": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "workingDirectories": [ + "file:///repo-a" + ] + } +} diff --git a/types/test-cases/reducers/242-session-workingdirectoryset-appends-to-existing-set.json b/types/test-cases/reducers/242-session-workingdirectoryset-appends-to-existing-set.json new file mode 100644 index 000000000..6b459c765 --- /dev/null +++ b/types/test-cases/reducers/242-session-workingdirectoryset-appends-to-existing-set.json @@ -0,0 +1,33 @@ +{ + "description": "session-workingDirectorySet appends to existing set", + "reducer": "session", + "initial": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "workingDirectories": [ + "file:///repo-a" + ] + }, + "actions": [ + { + "type": "session/workingDirectorySet", + "directory": "file:///repo-b" + } + ], + "expected": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "workingDirectories": [ + "file:///repo-a", + "file:///repo-b" + ] + } +} diff --git a/types/test-cases/reducers/243-session-workingdirectoryset-no-op-when-already-present.json b/types/test-cases/reducers/243-session-workingdirectoryset-no-op-when-already-present.json new file mode 100644 index 000000000..4d6dbbd77 --- /dev/null +++ b/types/test-cases/reducers/243-session-workingdirectoryset-no-op-when-already-present.json @@ -0,0 +1,32 @@ +{ + "description": "session-workingDirectorySet no-op when already present", + "reducer": "session", + "initial": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "workingDirectories": [ + "file:///repo-a" + ] + }, + "actions": [ + { + "type": "session/workingDirectorySet", + "directory": "file:///repo-a" + } + ], + "expected": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "workingDirectories": [ + "file:///repo-a" + ] + } +} diff --git a/types/test-cases/reducers/244-session-workingdirectoryremoved-removes-existing.json b/types/test-cases/reducers/244-session-workingdirectoryremoved-removes-existing.json new file mode 100644 index 000000000..bbac0bec5 --- /dev/null +++ b/types/test-cases/reducers/244-session-workingdirectoryremoved-removes-existing.json @@ -0,0 +1,33 @@ +{ + "description": "session-workingDirectoryRemoved removes existing", + "reducer": "session", + "initial": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "workingDirectories": [ + "file:///repo-a", + "file:///repo-b" + ] + }, + "actions": [ + { + "type": "session/workingDirectoryRemoved", + "directory": "file:///repo-a" + } + ], + "expected": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "workingDirectories": [ + "file:///repo-b" + ] + } +} diff --git a/types/test-cases/reducers/245-session-workingdirectoryremoved-no-op-when-not-in-set.json b/types/test-cases/reducers/245-session-workingdirectoryremoved-no-op-when-not-in-set.json new file mode 100644 index 000000000..75f080629 --- /dev/null +++ b/types/test-cases/reducers/245-session-workingdirectoryremoved-no-op-when-not-in-set.json @@ -0,0 +1,32 @@ +{ + "description": "session-workingDirectoryRemoved no-op when not in set", + "reducer": "session", + "initial": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "workingDirectories": [ + "file:///repo-a" + ] + }, + "actions": [ + { + "type": "session/workingDirectoryRemoved", + "directory": "file:///repo-b" + } + ], + "expected": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [], + "workingDirectories": [ + "file:///repo-a" + ] + } +} diff --git a/types/test-cases/reducers/246-session-workingdirectoryremoved-no-op-when-set-absent.json b/types/test-cases/reducers/246-session-workingdirectoryremoved-no-op-when-set-absent.json new file mode 100644 index 000000000..955bd9215 --- /dev/null +++ b/types/test-cases/reducers/246-session-workingdirectoryremoved-no-op-when-set-absent.json @@ -0,0 +1,26 @@ +{ + "description": "session-workingDirectoryRemoved no-op when set absent", + "reducer": "session", + "initial": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [] + }, + "actions": [ + { + "type": "session/workingDirectoryRemoved", + "directory": "file:///repo-a" + } + ], + "expected": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "ready", + "activeClients": [], + "chats": [] + } +} diff --git a/types/test-cases/reducers/247-chat-workingdirectoryset-adds-to-empty-set.json b/types/test-cases/reducers/247-chat-workingdirectoryset-adds-to-empty-set.json new file mode 100644 index 000000000..5b82e7917 --- /dev/null +++ b/types/test-cases/reducers/247-chat-workingdirectoryset-adds-to-empty-set.json @@ -0,0 +1,27 @@ +{ + "description": "chat-workingDirectorySet adds to empty set", + "reducer": "chat", + "initial": { + "turns": [], + "resource": "copilot:/test-session/chat-1", + "title": "Test Chat", + "status": 1, + "modifiedAt": "1970-01-01T00:00:01.000Z" + }, + "actions": [ + { + "type": "chat/workingDirectorySet", + "directory": "file:///repo-a" + } + ], + "expected": { + "turns": [], + "resource": "copilot:/test-session/chat-1", + "title": "Test Chat", + "status": 1, + "modifiedAt": "1970-01-01T00:00:01.000Z", + "workingDirectories": [ + "file:///repo-a" + ] + } +} diff --git a/types/test-cases/reducers/248-chat-workingdirectoryset-appends-to-existing-set.json b/types/test-cases/reducers/248-chat-workingdirectoryset-appends-to-existing-set.json new file mode 100644 index 000000000..1f962cd80 --- /dev/null +++ b/types/test-cases/reducers/248-chat-workingdirectoryset-appends-to-existing-set.json @@ -0,0 +1,31 @@ +{ + "description": "chat-workingDirectorySet appends to existing set", + "reducer": "chat", + "initial": { + "turns": [], + "resource": "copilot:/test-session/chat-1", + "title": "Test Chat", + "status": 1, + "modifiedAt": "1970-01-01T00:00:01.000Z", + "workingDirectories": [ + "file:///repo-a" + ] + }, + "actions": [ + { + "type": "chat/workingDirectorySet", + "directory": "file:///repo-b" + } + ], + "expected": { + "turns": [], + "resource": "copilot:/test-session/chat-1", + "title": "Test Chat", + "status": 1, + "modifiedAt": "1970-01-01T00:00:01.000Z", + "workingDirectories": [ + "file:///repo-a", + "file:///repo-b" + ] + } +} diff --git a/types/test-cases/reducers/249-chat-workingdirectoryset-no-op-when-already-present.json b/types/test-cases/reducers/249-chat-workingdirectoryset-no-op-when-already-present.json new file mode 100644 index 000000000..dee5eb6cc --- /dev/null +++ b/types/test-cases/reducers/249-chat-workingdirectoryset-no-op-when-already-present.json @@ -0,0 +1,30 @@ +{ + "description": "chat-workingDirectorySet no-op when already present", + "reducer": "chat", + "initial": { + "turns": [], + "resource": "copilot:/test-session/chat-1", + "title": "Test Chat", + "status": 1, + "modifiedAt": "1970-01-01T00:00:01.000Z", + "workingDirectories": [ + "file:///repo-a" + ] + }, + "actions": [ + { + "type": "chat/workingDirectorySet", + "directory": "file:///repo-a" + } + ], + "expected": { + "turns": [], + "resource": "copilot:/test-session/chat-1", + "title": "Test Chat", + "status": 1, + "modifiedAt": "1970-01-01T00:00:01.000Z", + "workingDirectories": [ + "file:///repo-a" + ] + } +} diff --git a/types/test-cases/reducers/250-chat-workingdirectoryremoved-removes-existing.json b/types/test-cases/reducers/250-chat-workingdirectoryremoved-removes-existing.json new file mode 100644 index 000000000..60b73b23d --- /dev/null +++ b/types/test-cases/reducers/250-chat-workingdirectoryremoved-removes-existing.json @@ -0,0 +1,31 @@ +{ + "description": "chat-workingDirectoryRemoved removes existing", + "reducer": "chat", + "initial": { + "turns": [], + "resource": "copilot:/test-session/chat-1", + "title": "Test Chat", + "status": 1, + "modifiedAt": "1970-01-01T00:00:01.000Z", + "workingDirectories": [ + "file:///repo-a", + "file:///repo-b" + ] + }, + "actions": [ + { + "type": "chat/workingDirectoryRemoved", + "directory": "file:///repo-a" + } + ], + "expected": { + "turns": [], + "resource": "copilot:/test-session/chat-1", + "title": "Test Chat", + "status": 1, + "modifiedAt": "1970-01-01T00:00:01.000Z", + "workingDirectories": [ + "file:///repo-b" + ] + } +} diff --git a/types/test-cases/reducers/251-chat-workingdirectoryremoved-no-op-when-not-in-set.json b/types/test-cases/reducers/251-chat-workingdirectoryremoved-no-op-when-not-in-set.json new file mode 100644 index 000000000..6dd23a805 --- /dev/null +++ b/types/test-cases/reducers/251-chat-workingdirectoryremoved-no-op-when-not-in-set.json @@ -0,0 +1,30 @@ +{ + "description": "chat-workingDirectoryRemoved no-op when not in set", + "reducer": "chat", + "initial": { + "turns": [], + "resource": "copilot:/test-session/chat-1", + "title": "Test Chat", + "status": 1, + "modifiedAt": "1970-01-01T00:00:01.000Z", + "workingDirectories": [ + "file:///repo-a" + ] + }, + "actions": [ + { + "type": "chat/workingDirectoryRemoved", + "directory": "file:///repo-b" + } + ], + "expected": { + "turns": [], + "resource": "copilot:/test-session/chat-1", + "title": "Test Chat", + "status": 1, + "modifiedAt": "1970-01-01T00:00:01.000Z", + "workingDirectories": [ + "file:///repo-a" + ] + } +} diff --git a/types/test-cases/reducers/252-chat-workingdirectoryremoved-no-op-when-set-absent.json b/types/test-cases/reducers/252-chat-workingdirectoryremoved-no-op-when-set-absent.json new file mode 100644 index 000000000..31b94eea8 --- /dev/null +++ b/types/test-cases/reducers/252-chat-workingdirectoryremoved-no-op-when-set-absent.json @@ -0,0 +1,24 @@ +{ + "description": "chat-workingDirectoryRemoved no-op when set absent", + "reducer": "chat", + "initial": { + "turns": [], + "resource": "copilot:/test-session/chat-1", + "title": "Test Chat", + "status": 1, + "modifiedAt": "1970-01-01T00:00:01.000Z" + }, + "actions": [ + { + "type": "chat/workingDirectoryRemoved", + "directory": "file:///repo-a" + } + ], + "expected": { + "turns": [], + "resource": "copilot:/test-session/chat-1", + "title": "Test Chat", + "status": 1, + "modifiedAt": "1970-01-01T00:00:01.000Z" + } +} diff --git a/types/version/registry.ts b/types/version/registry.ts index 8b82aac2d..e2ea83146 100644 --- a/types/version/registry.ts +++ b/types/version/registry.ts @@ -15,7 +15,7 @@ import type { ServerNotificationMap } from '../messages.js'; * * Formatted as a [SemVer](https://semver.org) `MAJOR.MINOR.PATCH` string. */ -export const PROTOCOL_VERSION = '0.6.1'; +export const PROTOCOL_VERSION = '0.7.0'; /** * Every protocol version a client built from this source tree is willing @@ -34,7 +34,7 @@ export const PROTOCOL_VERSION = '0.6.1'; * `scripts/verify-release-metadata.ts`. */ export const SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([ - '0.6.1', + '0.7.0', '0.6.0', '0.5.2', '0.5.1', @@ -90,6 +90,8 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.SessionServerToolsChanged]: '0.1.0', [ActionType.SessionActiveClientSet]: '0.5.0', [ActionType.SessionActiveClientRemoved]: '0.5.0', + [ActionType.SessionWorkingDirectorySet]: '0.7.0', + [ActionType.SessionWorkingDirectoryRemoved]: '0.7.0', [ActionType.SessionInputNeededSet]: '0.5.1', [ActionType.SessionInputNeededRemoved]: '0.5.1', [ActionType.SessionCustomizationsChanged]: '0.1.0', @@ -121,6 +123,8 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.ChatTurnCancelled]: '0.4.0', [ActionType.ChatError]: '0.4.0', [ActionType.ChatActivityChanged]: '0.5.0', + [ActionType.ChatWorkingDirectorySet]: '0.7.0', + [ActionType.ChatWorkingDirectoryRemoved]: '0.7.0', [ActionType.ChatUsage]: '0.4.0', [ActionType.ChatReasoning]: '0.4.0', [ActionType.ChatPendingMessageSet]: '0.4.0',