Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
e02cbe7
test(acpmux): a parent's message waits behind a working child's turn …
teamleaderleo Oct 20, 2026
e7a776b
feat(acpmux): sub-session messages reach a working child at its next …
teamleaderleo Oct 20, 2026
100508b
feat(acpmux): a parent reads what its sub-session waits on the user for
teamleaderleo Oct 21, 2026
2629b05
fix(acpmux): start_session's reply names where the user sees the child
teamleaderleo Oct 21, 2026
340765e
feat(agent-pane): a sub-session's chat names who wrote to it
teamleaderleo Oct 21, 2026
8339bb9
feat(agent-pane): sub-session cards in the transcript and the summary
teamleaderleo Oct 21, 2026
bc8aa3a
feat(acpmux): a session's summary says what it waits on the person for
teamleaderleo Oct 21, 2026
2bb0728
fix(acpmux): send_message queues behind the running turn by default; …
teamleaderleo Oct 21, 2026
bf479a0
test(acpmux): a prompt from inside another session names that session…
teamleaderleo Oct 21, 2026
ef4473c
fix(acpmux): a prompt sent from inside another session is that sessio…
teamleaderleo Oct 21, 2026
57b20b6
Merge remote-tracking branch 'origin/feat-cmux-next' into cmux/subses…
teamleaderleo Oct 21, 2026
a8c564b
fix(agent-pane): restore the base's strings the merge dropped
teamleaderleo Oct 21, 2026
7162cc3
Merge remote-tracking branch 'origin/feat-cmux-next' into cmux/subses…
teamleaderleo Oct 21, 2026
65c7029
test(acpmux): a session repeating itself with acpmux send is paused
teamleaderleo Oct 21, 2026
0bcccf6
fix(acpmux): review fixes: the pair guard counts session_message; a c…
teamleaderleo Oct 21, 2026
012d69c
Merge remote-tracking branch 'origin/feat-cmux-next' into cmux/subses…
teamleaderleo Oct 21, 2026
70ad627
fix(agent-pane): close the gallery's sub-session variant the merge le…
teamleaderleo Oct 21, 2026
b6f3808
Merge remote-tracking branch 'origin/feat-cmux-next' into cmux/subses…
teamleaderleo Oct 21, 2026
2ab6613
Merge origin/feat-cmux-next into cmux/subsession-visibility
teamleaderleo Oct 21, 2026
e8d1fa0
Merge branch 'feat-cmux-next' into cmux/subsession-visibility
teamleaderleo Oct 21, 2026
9cf1325
Merge origin/feat-cmux-next into cmux/subsession-visibility
teamleaderleo Oct 21, 2026
b41c3a6
agent-pane: ship SubsessionCallCard.css in the pane bundle
teamleaderleo Oct 21, 2026
f49dac8
Merge commit 'dad83b1ec7c18f7439e3a2c4864d538a71279789' into cmux/sub…
teamleaderleo Oct 21, 2026
120e70e
Merge remote-tracking branch 'origin/feat-cmux-next' into cmux/subses…
teamleaderleo Oct 21, 2026
966763e
Merge remote-tracking branch 'origin/feat-cmux-next' into cmux/subses…
teamleaderleo Oct 21, 2026
c3d719e
Merge remote-tracking branch 'origin/feat-cmux-next' into cmux/subses…
teamleaderleo Oct 21, 2026
0ad04ed
agent-pane strings: keep base's key order, adding only this PR's keys
teamleaderleo Oct 21, 2026
e42eabf
Merge origin/feat-cmux-next into cmux/subsession-visibility
teamleaderleo Oct 21, 2026
d480613
Merge origin/feat-cmux-next into cmux/subsession-visibility
teamleaderleo Oct 21, 2026
a7ec950
fix(acpmux): a peer reads a summary's new turn activity, plan step an…
teamleaderleo Oct 21, 2026
d25f9b0
cmux NEXT agent pane: a click on a sub-session opens its chat (chat.o…
teamleaderleo Oct 21, 2026
e635e32
chat.offer.open reaches the Mac handler; family check passed per pane
teamleaderleo Oct 21, 2026
4d6c2cf
Merge origin/feat-cmux-next into cmux/subsession-visibility
teamleaderleo Oct 21, 2026
bfcf404
Refuse a non-family offer.open with its own code so the page opens no…
teamleaderleo Oct 21, 2026
111d083
Merge remote-tracking branch 'origin/cmux/subsession-visibility' into…
teamleaderleo Oct 21, 2026
c5f5097
Merge feat-cmux-next into cmux/subsession-visibility-followups
teamleaderleo Oct 21, 2026
eb783f7
agent-pane strings: keep base's key order, adding only this PR's keys
teamleaderleo Oct 21, 2026
4398bf7
SDK resource-api descriptors: take base's catalog hash
teamleaderleo Oct 21, 2026
5737b81
Merge feat-cmux-next (with #22827 squashed) into cmux/subsession-visi…
teamleaderleo Oct 21, 2026
a229875
Open a Cloud VM chat's children on click, as chat.sideChat does
teamleaderleo Oct 21, 2026
35d59c7
Merge remote-tracking branch 'origin/feat-cmux-next' into cmux/subses…
teamleaderleo Oct 21, 2026
bca0b18
Open a Cloud VM chat's parent on click too
teamleaderleo Oct 21, 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
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,12 @@ public final class AgentTurnStateFeed {
nonisolated enum Message: Sendable {
/// The watch result: every session's state, last message and the muted sessions.
case page(AgentTurnStates, lastMessages: [String: String], muted: Set<String>, titles: [String: String],
generatedTitles: [String: String], details: [String: AgentChatDetail], lanes: [String: AgentActivityLane])
generatedTitles: [String: String], details: [String: AgentChatDetail], lanes: [String: AgentActivityLane],
startedBy: [String: String])
/// One `_acpmux/session_changed`: the session's new state (nil: none, or purged), last
/// message and mute.
/// message and mute, and the session that started it (nil for a root, or purged).
case change(session: String, state: AgentTurnState?, lastMessage: String?, muted: Bool, title: String?,
generatedTitle: String?, detail: AgentChatDetail?, lane: AgentActivityLane?)
generatedTitle: String?, detail: AgentChatDetail?, lane: AgentActivityLane?, startedBy: String?)

init?(line: Data) {
guard let message = try? JSONSerialization.jsonObject(with: line) as? [String: Any] else { return nil }
Expand All @@ -123,14 +124,16 @@ public final class AgentTurnStateFeed {
states.reset(result)
self = .page(states, lastMessages: AgentTurnStates.lastMessages(result), muted: AgentTurnStates.muted(result: result),
titles: AgentTurnStates.titles(result), generatedTitles: AgentTurnStates.titles(result, includingPrompt: false),
details: AgentChatDetail.details(result), lanes: Self.lanes(result))
details: AgentChatDetail.details(result), lanes: Self.lanes(result),
startedBy: AgentTurnStateStore.startedBy(result))
} else if message["method"] as? String == "_acpmux/session_changed", let params = message["params"] as? [String: Any],
let change = AgentTurnStates.change(params) {
let summary = params["kind"] as? String == "purged" ? nil : params["session"] as? [String: Any]
self = .change(session: change.session, state: change.state, lastMessage: change.lastMessage, muted: change.muted,
title: change.title, generatedTitle: change.generatedTitle,
detail: (params["session"] as? [String: Any]).flatMap(AgentChatDetail.of(summary:)),
lane: summary.flatMap { AgentActivityLane.of(summary: $0, id: change.session) })
lane: summary.flatMap { AgentActivityLane.of(summary: $0, id: change.session) },
startedBy: summary.flatMap(AgentTurnStateStore.startedBy(summary:)))
} else {
return nil
}
Expand All @@ -148,18 +151,20 @@ public final class AgentTurnStateFeed {

private func handle(_ message: Message) {
switch message {
case .page(let states, let lastMessages, let muted, let titles, let generatedTitles, let details, let lanes):
case .page(let states, let lastMessages, let muted, let titles, let generatedTitles, let details, let lanes, let startedBy):
backoff.reset()
if !isMirror, !turns.watching { turns.watching = true }
turns.replace(states: states, lastMessages: lastMessages, muted: muted, titles: titles,
generatedTitles: generatedTitles, details: details, source: source)
turns.replaceStartedBy(startedBy, source: source)
if self.lanes.lanes != lanes { self.lanes.lanes = lanes }
if !self.lanes.live { self.lanes.live = true }
case .change(let session, let state, let lastMessage, let muted, let title, let generatedTitle, let detail, let lane):
case .change(let session, let state, let lastMessage, let muted, let title, let generatedTitle, let detail, let lane, let startedBy):
// Most session records leave the indicator as it was: the store writes (and so
// redraws the tabs and rows that read this session) only what differs.
turns.apply(session: session, state: state, lastMessage: lastMessage, muted: muted, title: title,
generatedTitle: generatedTitle, detail: detail, source: source)
turns.setStartedBy(startedBy, session: session, source: source)
if lanes.lanes[session] != lane { lanes.lanes[session] = lane }
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public nonisolated struct AgentPageOps {
"pane.edit", "pane.setQuickActions", "tab.open", "run.attach", "tab.typeAhead", "tab.jump",
"newTab.remember", "newTab.setTemplate", "nowPlaying.command", "newTab.inputReady", "newTab.touched", "newTab.context", "newTab.warm", "shortcut.edit", "action.run", "file.open", "browser.open",
"project.list", "project.icon", "project.browse", "workspace.chooseFolder", "workspace.useFolder", "chat.folder.choose", "chat.attach.choose", "chat.attach.dropped", "app.action", "chats.open", "chats.openInTerminal", "chats.watch", "chats.page",
"quick.dismiss", "quick.openInWindow", "quick.startInBackground", "quick.setOnTop", "quick.setCollapsed", "quick.floatingState", "quick.context", "pane.action", "pane.tabState", "pane.tabRowTools", "chat.toolCard.respond", "chat.upload", "chat.sideChat", "chat.subagentPane", "chat.processTerminal", "chat.openTab", "chat.newHere", "chat.unsentDraft", "chat.scrollAnchor", "chat.scrollAnchor.get", "chat.tail", "chat.tail.get", "chat.draftAttachments", "chat.draftAttachments.get", "chat.asks.pending",
"quick.dismiss", "quick.openInWindow", "quick.startInBackground", "quick.setOnTop", "quick.setCollapsed", "quick.floatingState", "quick.context", "pane.action", "pane.tabState", "pane.tabRowTools", "chat.toolCard.respond", "chat.upload", "chat.sideChat", "chat.subagentPane", "chat.processTerminal", "chat.openTab", "chat.offer.open", "chat.newHere", "chat.unsentDraft", "chat.scrollAnchor", "chat.scrollAnchor.get", "chat.tail", "chat.tail.get", "chat.draftAttachments", "chat.draftAttachments.get", "chat.asks.pending",
"shell.run", "shell.read", "shell.stop", "harness.update", "harness.restart",
"git.diff", "git.status", "git.commit", "git.push", "git.worktree.create", "git.worktree.apply", "git.worktree.remove", "git.githubRepository", "file.search", "git.checkpoint.diff", "turn.undo", "git.stage", "git.unstage", "git.stageHunk", "git.unstageHunk", "git.pullRequest", "project.instructions", "project.addFiles", "composer.capture",
"dictation.toggle", "dictation.start", "dictation.stop", "dictation.cancel", "dictation.openSettings", "dictation.audio",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ public struct AgentPaneHeaderHooks {
/// Asks for the chat folder's pull request again (`pane.tabState {refresh: true}` at a turn's
/// end, cx-ncc.77.1); the answer is pushed with the header state.
public var refreshPullRequest: (@MainActor () -> Void)?
/// Shows and focuses the tab that shows acpmux session `id` already, so a chat never streams in
/// two tabs (``AgentPaneOfferOpen``); false when no tab shows it.
public var showSession: @MainActor (String) -> Bool
/// Whether acpmux session `session` is of chat `chat`'s family: a sub-session it started at any
/// depth, or the session that started it (acpmux `startedBy`, mirrored by the app's turn-state
/// watch). ``AgentPaneOfferOpen`` opens only those, or the pane's own sessions; none by default.
public var isFamily: @MainActor (_ session: String, _ chat: String) -> Bool

public init(run: @escaping @MainActor (String, String?) -> Void,
toggle: (@MainActor (String, String?) -> Void)? = nil,
Expand All @@ -54,7 +61,9 @@ public struct AgentPaneHeaderHooks {
preview: @escaping @MainActor (String?, Bool) -> Void = { _, _ in },
saveQuickActions: (@MainActor ([AgentPaneQuickAction]) async -> Bool)? = nil,
moveToFloatingChat: (@MainActor (String) -> Bool)? = nil,
refreshPullRequest: (@MainActor () -> Void)? = nil) {
refreshPullRequest: (@MainActor () -> Void)? = nil,
showSession: @escaping @MainActor (String) -> Bool = { _ in false },
isFamily: @escaping @MainActor (_ session: String, _ chat: String) -> Bool = { _, _ in false }) {
self.run = run
self.toggle = toggle ?? run
self.tabState = tabState
Expand All @@ -68,6 +77,8 @@ public struct AgentPaneHeaderHooks {
self.saveQuickActions = saveQuickActions ?? { _ in false }
self.moveToFloatingChat = moveToFloatingChat
self.refreshPullRequest = refreshPullRequest
self.showSession = showSession
self.isFamily = isFamily
}

/// The header's state (`pane.tabState`); `refresh` first asks for the pull request again.
Expand Down Expand Up @@ -164,6 +175,8 @@ extension AgentPaneModel {
return AgentPaneReply.failure(code: "unsupported", message: "Unsupported agent pane request: chat.openTab")
}
return AgentPaneReply.success()
case .offerOpen(let session):
return AgentPaneOfferOpen.respond(session, in: self)
case .newChatHere(let cwd):
guard sessionId != nil, newTab == nil, let header, header.newChat(cwd) else {
return AgentPaneReply.failure(code: "unsupported", message: "Unsupported agent pane request: chat.newHere")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ public final class AgentPaneModel {
case .watchChat(let key): if let onWatchChat { onWatchChat(key); return AgentPaneReply.success() } else { return Self.unsupported("chats.watch") }
case .chatsPage(let query): return await respondToChatsPage(query)
case .environment(let request): return await request.reply(store: startFolders.environments, newTabPage: newTab != nil && sessionId == nil)
case .paneAction, .preview, .setQuickActions, .tabState, .tabRowTools, .sideChat, .subagentPane, .processTerminal, .chatTab, .newChatHere: return await respondToHeader(request)
case .paneAction, .preview, .setQuickActions, .tabState, .tabRowTools, .sideChat, .subagentPane, .processTerminal, .chatTab, .offerOpen, .newChatHere: return await respondToHeader(request)
case .appAction(let id):
guard newTab?.omnibar.actions.contains(where: { $0.id == id }) == true, let onAppAction else { return Self.unsupported("app.action") }
onAppAction(id)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/// `chat.offer.open {sessionId}`: the person clicked a session the chat shows, a sub-session its
/// agent started (at any depth, a transcript card or the Subagents summary) or the session that
/// started this chat. The tab that shows it already is shown and focused; else it opens as a tab
/// in the chat's side pane, or a split right of the chat (``AgentSidePanes``), focused because a
/// person asked for it. Allowed for a session `chat.sideChat` may open (``AgentPaneSideChat/holds``,
/// which covers a Cloud VM chat's children), the Cloud VM chat's parent (``RemoteAgentSessionLinks``),
/// or of the chat's family (``AgentPaneHeaderHooks/isFamily``),
/// never the chat itself, and not from a New Tab page; any other session is refused with
/// `chat.offer.refused`, which the page takes as final.
/// Nothing opens unless the person clicks. Kept off ``AgentPaneModel``, whose type is at its size limit.
enum AgentPaneOfferOpen {
@MainActor static func respond(_ session: String, in model: AgentPaneModel) -> [String: Any] {
guard let chat = model.sessionId, model.newTab == nil, let header = model.header else {
return AgentPaneReply.failure(code: "unsupported", message: "Unsupported agent pane request: chat.offer.open")
}
// Not "unsupported": the page must not fall back to opening it in this tab instead.
let remoteParent = model.remoteChat && RemoteAgentSessionLinks.shared.parents[chat] == session
guard session != chat, AgentPaneSideChat.holds(session, chat: chat, in: model) || remoteParent || header.isFamily(session, chat) else {
return AgentPaneReply.failure(code: "chat.offer.refused", message: "chat.offer.refused")
}
if !header.showSession(session) { header.openSide(chat, session) }
return AgentPaneReply.success()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,8 @@ extension AgentPaneRequest {
if let cwd = (params?["cwd"] as? String).flatMap(Self.headerFolder) { self = .newChatHere(cwd) } else { self = .unsupported(method) }
case "chat.openTab":
if let id = params?["sessionId"] as? String, !id.isEmpty, id.count <= 256 { self = .chatTab(id, AgentForkDestination(placement: params?["placement"] as? String)) } else { self = .unsupported(method) }
case "chat.offer.open":
if let id = params?["sessionId"] as? String, !id.isEmpty, id.count <= 256 { self = .offerOpen(id) } else { self = .unsupported(method) }
case "chat.upload":
if let files = AgentPaneUpload.files(params) { self = .upload(files) } else { self = .unsupported(method) }
case "chat.toolCard.respond":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ extension AgentPaneRequest: CustomStringConvertible, CustomDebugStringConvertibl
case .tabRowTools: "tabRowTools"
case .sideChat: "sideChat"
case .chatTab: "chatTab"
case .offerOpen: "offerOpen"
case .subagentPane: "subagentPane"
case .processTerminal: "processTerminal"
case .newChatHere: "newChatHere"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ public nonisolated enum AgentPaneRequest: Equatable, Sendable {
/// A chat this one made (a fork, a Continue In…), opened at a destination of its own: a tab,
/// a split, a new workspace or a new window (`placement`; none is a tab).
case chatTab(String, AgentForkDestination)
/// `chat.offer.open {sessionId}`: the person opened a session the chat shows (a sub-session its
/// agent started, the session that started it): its tab, else one beside this chat, focused
/// (``AgentPaneOfferOpen``).
case offerOpen(String)
/// `chat.subagentPane {subagent, focus}`: this chat's subagent in an agent pane of its own,
/// right of the chat (``AgentSubagentPanes``); `focus` moves keyboard focus to it.
case subagentPane(String, focus: Bool)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import CmuxNextActions
import CmuxNextAgentPane
import CmuxNextBridge
import CmuxNextDaemon

/// The chat header and right-click menu (``AgentChatTabActions``), its toggling quick actions
Expand Down Expand Up @@ -46,7 +47,7 @@ extension AgentTabStore {
else { toggles.open(id, cwd: nil, url: url, chat: chat, store: store, actions: actions) }
}, saveQuickActions: { [weak self] list in
await self?.customization.saveQuickActions(list) ?? false
}, moveToFloatingChat: { [weak self] session in actions.moveToFloatingChat(session, via: self?.moveToFloatingChat) }, refreshPullRequest: pullRequest.askAgain)
}, moveToFloatingChat: { [weak self] session in actions.moveToFloatingChat(session, via: self?.moveToFloatingChat) }, refreshPullRequest: pullRequest.askAgain, showSession: { [weak self] session in guard let self, let shown = tab(showing: session, host: lookup(resolve(provisional))?.record.host) else { return false }; _ = actionRegistry?.perform("tab.focus", invocation: ActionInvocation(target: ActionTargetRef(kind: .tab, id: shown), origin: .user)); return true }, isFamily: { [weak self] session, chat in self?.turns.isFamily(session, of: chat) ?? false }) // a chat streams in one tab: an opened session's tab (on the chat's acpmux host, a Chief chat's `chief:<id>`) is shown; a chat opens only its family (startedBy)
model.chatMenuItems = actions.menuItems
model.onSearchWeb = actions.searchWeb
}
Expand Down
3 changes: 3 additions & 0 deletions Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ final class AgentTabStore {
/// The pane host for agent tab `key` when its session runs on another machine whose session
/// daemon serves `agent-session-attach-v1` (AppServices); nil keeps the "runs on" notice.
var remoteHost: @MainActor (String) -> (any AgentPaneHostProviding)? = { _ in nil }
/// The acpmux turn states and sub-session links (`startedBy`) the app's watches mirror: a chat's
/// page opens only sessions of its own family (``AgentPaneHeaderHooks/isFamily``).
var turns: AgentTurnStateStore = .shared
var chiefHost: String?, chiefPaneHost: (any AgentPaneHostProviding)?, localSessionHost: (any AgentPaneHostProviding)? // ChiefHomeAcpmux
/// Whether `daemon` holds agent session tabs (`agent-session-tabs-v1`).
var holdsTabs: @MainActor (DaemonService) -> Bool = { $0.supports(DaemonCapabilities.shared.agentSessionTabs) }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import Foundation

/// acpmux's sub-session links (`startedBy`, `hub/subsessions.rs`): the session whose agent started a
/// session with its `start_session` tool. Read from the session summaries of `_acpmux/watch` and its
/// `_acpmux/session_changed` pushes and kept beside the turn states, so a chat's page may open the
/// sessions of its own family (`chat.offer.open`) and nothing else.
extension AgentTurnStateStore {
/// How far a walk up the links goes: acpmux bounds depth far below this; a cycle in bad data stops here.
static let maxFamilyDepth = 64

/// The session that started the one `summary` describes; nil for a session a person or a
/// client started (no `startedBy`), and never the session itself.
public nonisolated static func startedBy(summary: [String: Any]) -> String? {
guard let parent = (summary["startedBy"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines),
!parent.isEmpty, parent != summary["sessionId"] as? String else { return nil }
return parent
}

/// The parent of every sub-session of a watch result, by sub-session id.
public nonisolated static func startedBy(_ result: [String: Any]) -> [String: String] {
var links: [String: String] = [:]
for summary in (result["sessions"] as? [Any] ?? []).compactMap({ $0 as? [String: Any] }) {
if let id = summary["sessionId"] as? String, let parent = startedBy(summary: summary) { links[id] = parent }
}
return links
}

// MARK: Writes (the watch feed)

/// Sets the session that started `session` (nil: a root session, or purged).
public func setStartedBy(_ parent: String?, session: String, source: String = AgentTurnStateStore.localSource) {
if let record = sessions[session] {
record.startedBy = parent
} else if let parent {
let record = Session()
record.source = source
record.startedBy = parent
sessions[session] = record
}
}

/// Replaces the links of every session of `source`'s watch with its page's.
public func replaceStartedBy(_ links: [String: String], source: String = AgentTurnStateStore.localSource) {
let own = sessions.compactMap { $0.value.source == source && $0.value.startedBy != nil ? $0.key : nil }
for id in Set(own).union(links.keys) { setStartedBy(links[id], session: id, source: source) }
}

// MARK: Reads

/// Whether `session` belongs to chat `chat`'s family: a sub-session under it at any depth, or
/// the session that started it. Never the chat itself.
public func isFamily(_ session: String, of chat: String) -> Bool {
guard session != chat else { return false }
if sessions[chat]?.startedBy == session { return true }
var current = session
for _ in 0..<Self.maxFamilyDepth {
guard let parent = sessions[current]?.startedBy else { return false }
if parent == chat { return true }
current = parent
}
return false
}
}
Loading