Skip to content

spec: multiroot sessions (draft for feedback)#337

Draft
sandy081 wants to merge 16 commits into
mainfrom
sandy081/agents/multiroot-sessions-support-spec
Draft

spec: multiroot sessions (draft for feedback)#337
sandy081 wants to merge 16 commits into
mainfrom
sandy081/agents/multiroot-sessions-support-spec

Conversation

@sandy081

Copy link
Copy Markdown
Member

Draft for design feedback — not ready to merge. Sharing the multiroot-sessions spec + proposal to gather review before further work.

What this is

A first-class multiroot sessions proposal for AHP: a session can grant its agent tool access to multiple working directories (equal peers, no privileged "primary"); a chat operates on a subset of those; and file changes group by directory.

The plan/spec document is docs/proposals/multiroot-sessions.md — start there. It covers the problem, mental model, what-it-is/isn't, a concrete protocol-surface summary (change table + TypeScript signatures in §8), design decisions, and open questions.

Protocol changes (all additive & optional, target 0.6.0)

  • CapabilityAgentCapabilities.multipleWorkspaceFolders gates the whole feature.
  • SessionCreateSessionParams.workingDirectories + SessionMetadata.workingDirectories (→ SessionState/SessionSummary); singular workingDirectory deprecated.
  • CommandsaddWorkspaceFolder / removeWorkspaceFolder (session) and addChatWorkspaceFolder / removeChatWorkspaceFolder (chat subset); removal is idempotent, modelled as reconfigure-to-reduced-set.
  • ChatChatState/ChatSummary/CreateChatParams.workingDirectories (subset ⊆ session); singular deprecated.
  • ChangesChangeset.workingDirectory + 'directory' changeKind; one changeset per directory instead of arrays-of-arrays.

Old clients that never set the fields / send the commands behave exactly as today. See §8 of the proposal for the full change table and signatures.

Open questions for reviewers

  • Keep workingDirectory deprecated (as here) vs. hard-remove it (pre-1.0 allows it)?
  • Make resolveSessionConfig / sessionConfigCompletions multiroot-aware? (currently keep their singular workingDirectory context.)
  • Do session lists need per-directory change rollups (ChangesSummary.byDirectory)?

Validation

npm test green — typecheck + lint + verify-release-metadata + verify-changelog + 316 reducer/message tests, 100% branch coverage. Schemas + all five clients regenerated.

Note

Branch is ~10 commits behind origin/main; will rebase before any real merge. Draft-only for feedback.

sandy081 and others added 6 commits July 13, 2026 10:25
- Add `MultipleWorkspaceFoldersCapability` and `AgentCapabilities.multipleWorkspaceFolders`
  to `types/channels-root/state.ts`, mirroring the `multipleChats` pattern.
  Presence signals the agent supports multiple equal-peer working directories.

- Add `CreateSessionParams.workingDirectories` (plural, replaces the singular
  `workingDirectory`) to `types/channels-session/commands.ts`.
  The old singular field is retained as a deprecated backwards-compatible
  shorthand. Forked sessions ignore `workingDirectories` and inherit
  from the source session.

- Add `AddWorkspaceFolderParams`, `RemoveWorkspaceFolderParams`,
  `WorkspaceFolderResult`, `AddWorkspaceFolderResult`, and
  `RemoveWorkspaceFolderResult` to `types/channels-session/commands.ts`.
  Both commands return the full directory set after the mutation.
  `removeWorkspaceFolder` is modelled as idempotent reconfigure-to-reduced-set
  (no server-side atomic remove primitive).

- Register `addWorkspaceFolder` and `removeWorkspaceFolder` in `CommandMap`
  in `types/common/messages.ts` and `_ExpectedCommands` in
  `types/version/message-checks.ts`.

- Version-gating: new commands and capability land in AHP 0.6.0 (a 0.x minor
  bump per the versioning doc, matching a capability boundary). Gating is
  purely via the `multipleWorkspaceFolders` capability + version handshake.
  The `ACTION_INTRODUCED_IN` / `NOTIFICATION_INTRODUCED_IN` maps are not
  touched because neither new symbols are state actions nor notifications.

- Update `CHANGELOG.md` (spec and all five clients) with `## [Unreleased]`
  entries under `### Added` / `### Deprecated`.

- Update `docs/guide/state-model.md` with a new "Multiroot Sessions" section
  plus updated `SessionState` / `SessionSummary` snippets.

- Regenerate all client mirrors (Swift, Rust, Kotlin, TypeScript, Go) and
  update the four generator scripts (`generate-swift.ts`, `generate-rust.ts`,
  `generate-kotlin.ts`, `generate-go.ts`) with the new type registrations
  and helper methods.

All 312 tests pass; 100% branch coverage on reducers.ts maintained.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Extends the multiroot session design with per-chat directory subsets:

- Add `SessionMetadata.workingDirectories` (on `SessionState` and
  `SessionSummary`), the mirror of the `CreateSessionParams.workingDirectories`
  set as maintained by `addWorkspaceFolder`/`removeWorkspaceFolder`. Deprecate
  the singular `workingDirectory` on `SessionMetadata`; hosts SHOULD keep
  populating it (first entry) for backwards compatibility.

- Add `ChatState.workingDirectories` and `ChatSummary.workingDirectories`:
  the subset of the session's `workingDirectories` that this chat's agent has
  tool access to. When absent the chat inherits the full session set. Every
  entry must be present in the session's set. Deprecate the singular
  `workingDirectory` on both.

- Add `CreateChatParams.workingDirectories`: initial subset at create time.
  Ignored for forked chats (they inherit the source chat's set). Gated on
  `multipleWorkspaceFolders` capability.

- Add `addChatWorkspaceFolder` and `removeChatWorkspaceFolder` commands
  (symmetrical to the session-level pair) for mutating the chat's subset on a
  running chat. The add command enforces the subset invariant (server rejects
  any directory not in the session's set). The remove command is idempotent.
  Both return `AddChatWorkspaceFolderResult` / `RemoveChatWorkspaceFolderResult`
  carrying the chat's full subset after the mutation.

- Register both commands in `CommandMap`, `_ExpectedCommands`, all four
  generator scripts, and all CHANGELOGs.

- Update `docs/guide/state-model.md` with a "Per-chat working-directory
  subsets" subsection and updated `ChatState` code snippet.

All 316 tests pass; 100% branch coverage maintained.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add an optional Changeset.workingDirectory (with a 'directory' changeKind
hint) so a multiroot session groups its file changes by directory —
advertising one changeset per working directory rather than nesting
changes as arrays-of-arrays. Also add the shareable multiroot-sessions
feature proposal under docs/proposals/.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The @Version JSDoc tag is a per-message revision counter (integer,
starting at 1 for a brand-new message), not the protocol semver. The
new multiroot commands were mistakenly tagged @Version 0.6.0; align
them with the convention used by every other first-revision message.
Documentation-only: nothing parses @Version, so no generated output
changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Align the pre-existing ping command with the @Version JSDoc convention
(a per-message revision counter, not the protocol semver). The last
message carrying a semver-shaped @Version. Documentation-only; no
generated output changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Expand the proposal's protocol-surface section with a per-symbol change
table and concrete TypeScript signatures (capability, session/chat
working-directory fields + deprecations, the four add/remove commands,
Changeset.workingDirectory, CommandMap entries) plus a versioning/gating
note, so reviewers can grasp the exact protocol delta at a glance.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment thread types/channels-chat/commands.ts Outdated
Comment thread types/channels-chat/state.ts Outdated
Comment thread types/channels-root/state.ts Outdated
Comment thread types/channels-session/commands.ts Outdated
Comment thread CHANGELOG.md
Comment thread types/channels-changeset/state.ts Outdated
* Omit for changesets that are not directory-scoped (e.g. a single-directory
* session, or a session-wide roll-up spanning every directory).
*/
workingDirectory?: URI;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Would it work to say that Changesets always just cover all directories? Then you can have one changeset which covers all changes made, and it should be easy to see which dir a particular change is part of, based on which dir the file is in.

Also I'm not sure how the "Other files" work today, we can already track changes outside of a working dir

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yea, that also avoids future questions about nested git repos etc., I would drop this unless we have an experience we struggle to drive without it

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I'd actually push to keep this and go further — make server-side grouping a normative MUST (while the field stays optional). My reasoning is that grouping changes by directory is a host responsibility, not a client one.

Per AHP's doctrine, AHP is a "client-facing presentation model" giving clients "display-ready session state ... without requiring the client to understand a particular filesystem model," with the "host [owning] the authoritative state." Deriving a file's directory by prefix-matching its URI against the session's working directories is a filesystem model — and if the server doesn't do it, every client (VS Code, CLIs, web, mobile) re-implements the same grouping independently and inconsistently. That's exactly the semantic work AHP is meant to centralize in the host.

And the client can't even do it correctly: only the host knows the real VCS boundaries. A file under repo-a/ may actually live in a nested repo-a/vendor/repo-c/ git repo; longest-prefix matching gets that wrong, and the client can't see symlinks, submodules, or worktrees. The host is the only party that knows which root a change truly belongs to — so grouping belongs where that knowledge is.

So rather than dropping it, I've changed the spec to say: a host with multiple working directories MUST emit one changeset per directory (each with workingDirectory set). The field stays optional only so the cases you both raised still work — a single-directory session, and out-of-tree / aggregate changesets that intentionally span or sit outside the working dirs (Rob's "Other files"). So nothing we can track today is lost, a minimal client can still ignore the field, and it's gated by multipleWorkspaceFolders.

Bonus: it also fits the existing catalogue (the host already advertises session/turn/compare-turns/branch/uncommitted views — directory is one more server-owned slice), and gives per-repo operations like create-pr/commit a natural scope, since those are inherently per-repo with sibling repositories.

On nested repos specifically — I'd argue an explicit workingDirectory on the changeset resolves the ambiguity rather than creating it: the host states which root owns the changeset instead of leaving clients to guess.

@roblourens roblourens Jul 16, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't understand the point about VCS boundaries- we care about matching the file to a directory, which is a list we already have, not a git repo.

Also, I'm not actually sure that the client would typically care to group by working directory anyway, does it matter? I think I'd render a tree for the session and call it a day.

But if you want to group them, understanding URIs at this level doesn't seem like it's making wild assumptions about the remote.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

You've convinced me — dropped Changeset.workingDirectory (and the 'directory' changeKind). You're right that matching a file to one of the session's directories is a simple client-side operation against a list we already have, not a VCS-boundary problem, so the "clients can't do it" argument doesn't hold.

There's also a modelling flaw I'd missed: a changeset can span directories (a per-turn diff touches several), so a single-directory scalar can't represent the common case anyway. So the new shape is: changesets stay cross-cutting; a client that wants a per-directory view groups the files itself against workingDirectories; and a host MAY additionally advertise dedicated per-directory changesets as extra catalogue entries (the changesets list is already unbounded — no schema change needed). If we later want machine-readable per-directory entries, a {workingDirectory} uriTemplate variable is the natural follow-up.

@sandy081 sandy081 Jul 20, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@lszomoru lszomoru Jul 21, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@sandy081, I would like to point out what while some changeset may span multiple working directories (ex: last turn changes), some changesets are very much scoped to a git repository (ex: branch changes). Branch changes which currently is the default changeset for a session, compares branchA with main with the branch information even present in the changeset description which is surfaced in the UI. In light of that I would like to suggest to reconsider the optional workingDirectory on the changeset.

Comment thread types/channels-chat/state.ts Outdated
- Directory mutations are now state actions, not commands: replace the four
  add/remove(Chat)WorkspaceFolder commands with client-dispatchable
  session/workingDirectorySet, session/workingDirectoryRemoved,
  chat/workingDirectorySet, chat/workingDirectoryRemoved actions (keyed
  by the directory URI, idempotent), with reducers, ACTION_INTRODUCED_IN
  entries (0.6.0), and full-branch conformance fixtures.
- Hard-remove the deprecated singular `workingDirectory` from
  CreateSessionParams, SessionMetadata, ChatState, and ChatSummary (0.6.0
  is a breaking release). Update hand-written client reducers/mergers in
  every language accordingly.
- Add `immutablePrimary?` to MultipleWorkspaceFoldersCapability so backends
  that pin a fixed process root can advertise it while keeping equal peers
  the default.
- Keep Changeset.workingDirectory and strengthen it to a normative MUST:
  a multiroot host groups changes by directory (server-side), per AHP's
  display-ready-state doctrine.
- Move the CHANGELOG entries into a docs/.changes fragment.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment thread types/channels-root/state.ts Outdated
sandy081 and others added 3 commits July 17, 2026 11:32
…nd 2)

- Rename the capability `multipleWorkspaceFolders` ->
  `multipleWorkingDirectories` (and `MultipleWorkspaceFoldersCapability` ->
  `MultipleWorkingDirectoriesCapability`) for consistency with the
  `workingDirectory` vocabulary used everywhere else (per DonJayamanne).
- Drop `Changeset.workingDirectory` and the `'directory'` changeKind. A
  changeset can span working directories (e.g. a per-turn diff), so a
  single-directory scalar is the wrong model. Per-directory presentation is
  optional: clients group a changeset's files against the session's
  workingDirectories, and a host MAY advertise extra per-directory catalogue
  entries (the changesets list is already unbounded). Resolves the reviewer
  thread rather than holding the MUST.
- Update state-model / changesets guides and the proposal accordingly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…root-sessions-support-spec

# Conflicts:
#	types/channels-chat/actions.ts
CI (cargo/go/gradle/swift test) went red after the merge because the four
new working-directory state actions were only handled by the TypeScript
reducer — the hand-written Rust/Go/Kotlin/Swift reducers no-op'd them, so
the shared conformance fixtures (append/remove cases) failed. Add the
session/chat workingDirectorySet + workingDirectoryRemoved cases to each
native reducer (idempotent set / no-op remove, mirroring activeClientSet).

Also fix two Rust integration-test struct literals that still set the
removed singular `working_directory` field (cargo test compiles tests;
the earlier local `cargo build` did not, so it slipped through).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
connor4312
connor4312 previously approved these changes Jul 21, 2026
@sandy081
sandy081 marked this pull request as ready for review July 21, 2026 19:07
sandy081 and others added 2 commits July 21, 2026 14:14
spec/v0.6.0 released on 2026-07-20 without multiroot and still contains
the singular `workingDirectory` fields this feature removes. Removing
released state fields breaks compatibility within the 0.6.x MINOR, so
per the versioning policy this feature must land in a new MINOR.

- Bump PROTOCOL_VERSION 0.6.1 -> 0.7.0; SUPPORTED_PROTOCOL_VERSIONS =
  [0.7.0, 0.6.0, 0.5.2, 0.5.1].
- Retag the four working-directory actions ACTION_INTRODUCED_IN 0.6.0 -> 0.7.0
  (0.6.0 shipped without them, so a 0.6.0 peer does not understand them).
- Rotate the CHANGELOG dev heading 0.6.1 -> 0.7.0 and update the proposal's
  versioning section.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
connor4312
connor4312 previously approved these changes Jul 21, 2026
@connor4312

Copy link
Copy Markdown
Member

0.7.0 is fine here, this is a breaking change

@sandy081
sandy081 enabled auto-merge July 21, 2026 23:59
@sandy081
sandy081 disabled auto-merge July 22, 2026 00:03
… chat)

Per finalized design:
- Rename the capability flag immutablePrimary -> requiresPrimary. It now
  means "the agent needs one directory designated as primary" (a distinguished
  root), rather than "index 0 is a fixed process root". Primacy is explicit,
  never inferred from array position.
- Add primaryWorkingDirectory (URI) to CreateSessionParams + SessionMetadata
  (-> SessionState/SessionSummary) and to CreateChatParams + ChatState/
  ChatSummary. A chat inherits the session's primary when it omits its own.
- Update hand-written client reducers/mergers (Go/Rust/Kotlin/Swift) to carry
  primaryWorkingDirectory through session/chatUpdated, add the field to Go's
  hand-maintained PartialChatSummary, and fix Rust test struct literals.
- Docs (state-model, proposal) and changelog fragment updated.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@sandy081

sandy081 commented Jul 22, 2026

Copy link
Copy Markdown
Member Author

Update: requiresPrimary capability + explicit primaryWorkingDirectory (session & chat)

Pushed in 148c716b. This reworks the "primary directory" concept based on offline discussion with @TylerLeonhardt

What changed

  1. Renamed the capability flag immutablePrimaryrequiresPrimary. It now means "the agent needs one of its working directories designated as the primary" (a distinguished root it centers on — process root, default for relative paths), rather than "index 0 is a fixed process root".

  2. Primacy is now explicit, never positional. Added a primaryWorkingDirectory: URI field so the primary is designated explicitly rather than inferred from array index. This keeps the "directories are equal peers, order carries no meaning" invariant intact — removing/reordering entries can't silently repromote a new primary.

    • CreateSessionParams.primaryWorkingDirectory (⊆ workingDirectories) → reported on SessionState.primaryWorkingDirectory / SessionSummary.
    • CreateChatParams.primaryWorkingDirectory (⊆ the chat's dirs) → reported on ChatState/ChatSummary.primaryWorkingDirectory. A chat inherits the session's primary when it omits its own.
  3. When requiresPrimary is set, a client SHOULD supply primaryWorkingDirectory; a host MAY reject creation that omits it

return state;
}
const updated = list.slice();
updated.splice(idx, 1);

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: Could this removal be rejected unless related state is updated transactionally? As written it can leave primaryWorkingDirectory pointing outside the session set and existing chat subsets referencing a directory the session no longer owns. There is also no action for reassigning the primary before removing it.

Comment thread types/version/registry.ts
*/
export const SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([
'0.6.1',
'0.7.0',

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: Can clients built from this source still truthfully advertise 0.6.0 fallback? CreateSessionParams has removed the singular workingDirectory, so after negotiating 0.6 this client no longer has a way to construct that version's session-creation wire shape.

// ── Working Directories ───────────────────────────────────────────────

case ActionType.ChatWorkingDirectorySet: {
const list = state.workingDirectories ?? [];

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: When workingDirectories is absent, the state contract says this chat inherits the full session set. Here absence is treated as an empty set, so adding one directory narrows the inherited set to a singleton, while the removal case below cannot remove an inherited directory. How can these actions preserve the documented inheritance semantics without access to the session state?

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;

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: Should this also merge changes.primaryWorkingDirectory? Without it, the cached session summary can retain a stale primary. The Rust and Swift host runtime summary mergers have the same omission.

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?


case ActionType.SessionWorkingDirectorySet: {
const list = state.workingDirectories ?? [];
if (list.includes(action.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: What defines URI identity for this set? Every reducer currently uses raw string equality, so equivalent URI spellings can be duplicated or fail removal across clients. The protocol should define wire-level equality or provide conformance fixtures for the intended behavior.

DonJayamanne
DonJayamanne previously approved these changes Jul 22, 2026
@sandy081
sandy081 marked this pull request as draft July 22, 2026 06:00
…xed field

Finalized design for "primary working directory":

- The SESSION has no primary. Dropped primaryWorkingDirectory from
  SessionMetadata (SessionState/SessionSummary). The session is purely an
  equal-peer directory set.
- Primary is a PER-CHAT notion: ChatState.primaryWorkingDirectory (mirrored on
  ChatSummary) is user-visible read-only state, fixed at chat creation. There is
  no action to change it and it does not participate in session/chatUpdated —
  "in state" (so late subscribers can read it) but immutable (no action).
- Set via CreateChatParams.primaryWorkingDirectory; CreateSessionParams.
  primaryWorkingDirectory now seeds the session's default chat.
- Reworded the requiresPrimary capability accordingly (per-chat, fixed at
  creation) and the session/workingDirectoryRemoved decline note.
- Removed the primaryWorkingDirectory handling from the client chatUpdated
  partial-summary merges (immutable → not a partial-update field) and dropped
  it from the Session rust struct literals.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants