Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5d2a82e
spec: add multiroot session support (AHP 0.6.0)
sandy081 Jul 13, 2026
d53cdac
spec: per-chat working-directory subsets (AHP 0.6.0)
sandy081 Jul 16, 2026
2bd50e6
spec: group multiroot changes by working directory + proposal doc
sandy081 Jul 16, 2026
4bcb754
spec: use @version 1 for new multiroot commands (JSDoc convention)
sandy081 Jul 16, 2026
71d6b5f
spec: fix @version on ping (0.1.0 -> 1)
sandy081 Jul 16, 2026
189f46c
docs: add type-change summary to multiroot proposal
sandy081 Jul 16, 2026
5495b7f
spec: address review feedback on multiroot PR #337
sandy081 Jul 16, 2026
1ebc206
spec: rename capability + drop Changeset.workingDirectory (review rou…
sandy081 Jul 17, 2026
64349e5
Merge remote-tracking branch 'origin/main' into sandy081/agents/multi…
sandy081 Jul 18, 2026
11f1a65
fix(clients): implement working-directory actions in native reducers
sandy081 Jul 18, 2026
f2be3ad
Merge remote-tracking branch 'origin/main' into sandy081/agents/multi…
sandy081 Jul 21, 2026
40eb488
spec: target 0.7.0 for multiroot (0.6.0 shipped without it)
sandy081 Jul 21, 2026
0d715aa
Revert "spec: target 0.7.0 for multiroot (0.6.0 shipped without it)"
sandy081 Jul 21, 2026
d7c7d5a
Reapply "spec: target 0.7.0 for multiroot (0.6.0 shipped without it)"
sandy081 Jul 21, 2026
148c716
spec: rename requiresPrimary + add primaryWorkingDirectory (session +…
sandy081 Jul 22, 2026
ea279d9
spec: make primaryWorkingDirectory a per-chat, read-only, creation-fi…
sandy081 Jul 22, 2026
d533dbd
docs: explain why primaryWorkingDirectory is on CreateSessionParams
sandy081 Jul 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
36 changes: 34 additions & 2 deletions clients/go/ahp/reducers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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 {
Expand Down
87 changes: 87 additions & 0 deletions clients/go/ahptypes/actions.generated.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() {}
Expand Down Expand Up @@ -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 {
Expand Down
56 changes: 54 additions & 2 deletions clients/go/ahptypes/commands.generated.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions clients/go/ahptypes/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: omitempty collapses a present-but-empty slice into an absent field, but the chat contract distinguishes explicit empty (no working-directory access) from absent (inherit the session set). Can the Go representation preserve that distinction, or should the protocol drop the explicit-empty semantic?

PrimaryWorkingDirectory *URI `json:"primaryWorkingDirectory,omitempty"`
}

// ─── StringOrMarkdown ────────────────────────────────────────────────────
Expand Down
14 changes: 9 additions & 5 deletions clients/go/ahptypes/notifications.generated.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:/<uuid>/annotations`). Surfaced so badge UI can render
// annotation / entry counts without subscribing. Absent when the session
Expand Down
Loading
Loading