Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
26 changes: 22 additions & 4 deletions Native/OptChat/optchat-chief/src/acpmux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,33 @@ pub fn chief_tags(home_id: &str, role: &str) -> BTreeMap<String, String> {
])
}

/// On a turn session: the Chief's participant id (`agent_mux`, `agent_…`).
/// acpmux stamps it as `fromChief` on a message the turn sends another chat
/// (its `prompt_provenance::session_message_from`), and the app draws that
/// Chief's name and picture there, not the turn's session name
/// (manaflow-ai/cmux#27914).
pub const CHIEF_PARTICIPANT_TAG: &str = "cmux.chief.participant";

/// A turn session's tags: [`chief_tags`] plus the names acpmux puts on its
/// approval cards (Leo 2026-10-21, #89(4): "Chief wants to message a12 ·
/// Nightly train"): the asker is the Chief, and its children are the
/// sessions whose `mux.parent` is `parent`.
pub fn turn_tags(home_id: &str, parent: &str) -> BTreeMap<String, String> {
/// Nightly train"): the asker is the Chief by its own name (`title`, else
/// "Chief"), and its children are the sessions whose `mux.parent` is
/// `parent`; and the Chief's `participant` id.
pub fn turn_tags(
home_id: &str,
parent: &str,
participant: &str,
title: Option<&str>,
) -> BTreeMap<String, String> {
use cmux_chief::rules::{CHIEF_DISPLAY_NAME, CHILDREN_TAG, LABEL_TAG};
let mut tags = chief_tags(home_id, "turn");
tags.insert(LABEL_TAG.to_owned(), CHIEF_DISPLAY_NAME.to_owned());
let label = title
.map(|t| t.split_whitespace().collect::<Vec<_>>().join(" "))
.filter(|t| !t.is_empty())
.unwrap_or_else(|| CHIEF_DISPLAY_NAME.to_owned());
tags.insert(LABEL_TAG.to_owned(), label);
tags.insert(CHILDREN_TAG.to_owned(), parent.to_owned());
tags.insert(CHIEF_PARTICIPANT_TAG.to_owned(), participant.to_owned());
tags
}

Expand Down
9 changes: 8 additions & 1 deletion Native/OptChat/optchat-chief/src/brain/turns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -684,7 +684,14 @@ impl Brain {
model: engine.model.clone(),
effort: engine.effort.clone(),
preset,
tags: crate::acpmux::turn_tags(&self.settings.chief_id, &self.settings.parent),
// The Chief's own name: `MUX_CHIEF_TITLE`, which the app
// gives each Chief's brain (a rename restarts it).
tags: crate::acpmux::turn_tags(
&self.settings.chief_id,
&self.settings.parent,
&self.settings.membership.participant,
crate::cli::env("MUX_CHIEF_TITLE").as_deref(),
),
// Only the Chief's own turn gets the seal its `chiefs`
// tool needs (chief_profile.rs TURN_KEY_ENV).
env: [(
Expand Down
6 changes: 6 additions & 0 deletions Native/OptChat/optchat-chief/tests/approval_display_tags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,4 +107,10 @@ fn a_turn_names_the_chief_and_whose_children_it_messages() {
tags.get(CHILDREN_TAG).map(String::as_str),
Some(optchat_chief::brain::PARENT)
);
// Which Chief it is: a message it sends another chat shows that Chief
// (manaflow-ai/cmux#27914).
assert_eq!(
tags.get(optchat_chief::acpmux::CHIEF_PARTICIPANT_TAG).map(String::as_str),
Some("agent_mux")
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public nonisolated struct AgentPageOps {
"pane.checkpointAvailability", "pane.framePacing", "pane.painted", "pane.renderRate", "pane.saveLog", "pane.saveFile", "pane.showContextUsage", "pane.showDictation", "pane.setScrollToBottomOnSend", "pane.setDisplay", "pane.setAttachmentView",
"pane.edit", "pane.setQuickActions", "tab.open", "run.attach", "tab.typeAhead", "tab.jump",
"newTab.remember", "newTab.setTemplate", "newTab.editWidgets", "nowPlaying.command", "newTab.inputReady", "newTab.touched", "newTab.context", "newTab.warm", "shortcut.edit", "action.run", "file.open", "browser.open",
"project.list", "project.icon", "agent.avatar", "agent.avatar.set", "agent.avatar.remove", "harness.symbol", "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",
"project.list", "project.icon", "agent.avatar", "agent.avatar.set", "agent.avatar.remove", "chief.face", "harness.symbol", "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.computerUsePreview", "chat.upload", "chat.btw.inline", "chat.sideChat", "chat.subagentPane", "chat.processTerminal", "chat.openTab", "chat.offer.open", "chat.asks.open", "chat.asks.answer", "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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ public final class AgentPaneModel {
@ObservationIgnored public var onAgentAvatar: ((String) async -> String?)?
@ObservationIgnored public var onSetAgentAvatar: ((String) async -> String?)?
@ObservationIgnored public var onRemoveAgentAvatar: ((String) -> Void)?
/// `chief.face`: Chief `chief`'s name and avatar as the sidebar shows them (`name`, `src` or `text`).
@ObservationIgnored public var onChiefFace: ((String) async -> [String: String])?
/// Returns bounded project paths for the picker, optionally filtered by query.
@ObservationIgnored public var onListProjects: ((String?) async -> [String])?
/// The folders a chat on another machine can pick: that machine's (its workspace's folders
Expand Down Expand Up @@ -286,7 +288,7 @@ public final class AgentPaneModel {
// Boot traffic, and a request the host refused (it changed nothing), leave it untouched.
case .ready, .reconnect, .uiPointerReport, .framePacing, .renderRate, .checkpointAvailability, .painted, .newTabInputReady, .newTabContext, .newTabWarm, .unsupported,
.transportOpen, .transportSend, .transportClose, .transportGesture, .transportGestureRelease, .chatsPage, .quickFloatingState, .quickContext,
.tabRowTools, .projectIcon, .agentAvatar, .harnessSymbol, .environment(.list), .cloudMachine(.list), .sandboxState: break
.tabRowTools, .projectIcon, .agentAvatar, .chiefFace, .harnessSymbol, .environment(.list), .cloudMachine(.list), .sandboxState: break
case .reply(let reply) where reply.isPassive: break
default:
if !userTouched { touchedBy = String(String(describing: request).prefix { $0 != "(" }) }
Expand Down Expand Up @@ -380,6 +382,8 @@ public final class AgentPaneModel {
case .removeAgentAvatar(let session):
onRemoveAgentAvatar?(session)
return AgentPaneReply.success()
case .chiefFace(let chief):
return AgentPaneReply.success(await onChiefFace?(chief) ?? [:])
case .projectIcon where remoteChat:
// A chat on another machine: its folders are there, and this Mac's disk is never read (cx-k21v).
return AgentPaneReply.success()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,12 @@ extension AgentPaneRequest {
if let session = params?["sessionId"] as? String, !session.isEmpty, session.utf8.count <= 256 {
self = method == "agent.avatar" ? .agentAvatar(session) : method == "agent.avatar.set" ? .setAgentAvatar(session) : .removeAgentAvatar(session)
} else { self = .unsupported(method) }
case "chief.face":
// A Chief's participant id (acpmux `fromChief`): `agent_` and a short token.
if let chief = params?["chief"] as? String, chief.hasPrefix("agent_"), (7...64).contains(chief.utf8.count),
chief.utf8.allSatisfy({ $0 == UInt8(ascii: "_") || $0 == UInt8(ascii: "-") || (0x30...0x39).contains($0) || (0x41...0x5A).contains($0) || (0x61...0x7A).contains($0) }) {
self = .chiefFace(chief)
} else { self = .unsupported(method) }
case "harness.symbol":
if let name = params?["name"] as? String, HarnessSymbolImages.isSymbolName(name) { self = .harnessSymbol(name) }
else { self = .unsupported(method) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ extension AgentPaneRequest: CustomStringConvertible, CustomDebugStringConvertibl
case .agentAvatar: "agentAvatar"
case .setAgentAvatar: "setAgentAvatar"
case .removeAgentAvatar: "removeAgentAvatar"
case .chiefFace: "chiefFace"
case .harnessSymbol: "harnessSymbol"
case .nowPlaying: "nowPlaying"
case .openChat: "openChat"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ public nonisolated enum AgentPaneRequest: Equatable, Sendable {
case setAgentAvatar(String)
/// `agent.avatar.remove {sessionId}`: Remove Avatar: the session's harness mark again.
case removeAgentAvatar(String)
/// `chief.face {chief}`: the name and avatar Chief `chief` (a participant id, `agent_mux` or
/// `agent_…`) shows in the sidebar and Home, for a message it sent this chat (acpmux
/// `fromChief`, manaflow-ai/cmux#27914): `name`, and `src` (a small data URL) or `text`.
case chiefFace(String)
/// `harness.symbol {name}`: a custom harness's SF Symbol icon as a template PNG `data:` URL
/// (``HarnessSymbolImages``); a name this Mac cannot draw answers nothing.
case harnessSymbol(String)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,10 @@ extension AgentTabStore {
linkScheme: services.linkScheme, settings: services.settings, openPanel: services.openPanel)
tabs.launchImages = services.launchImages
tabs.agentAvatars = AgentAvatars(uploads: services.chiefAvatarUploads, openPanel: services.openPanel)
tabs.chiefFace = { [weak services, weak tabs] chief in
guard let services, let avatars = tabs?.agentAvatars else { return [:] }
return await services.home.agentPageFace(of: chief, avatars: avatars)
}
let state = services.environment.sidebarSnapshotFile?.url.deletingLastPathComponent()
tabs.unsentDrafts = AgentUnsentDrafts(directory: state)
tabs.pageChats.environments = state.map { SavedEnvironments(url: $0.appending(path: "environments.json")) }
Expand Down Expand Up @@ -387,6 +391,7 @@ extension AgentTabStore {
self?.agentAvatars?.remove(session)
self?.agentAvatarChanged(session)
}
model.onChiefFace = { [weak self] chief in await self?.chiefFace?(chief) ?? [:] }
}

/// Every agent page (tabs and standalone chats) asks for session `session`'s avatar again: a
Expand Down
2 changes: 2 additions & 0 deletions Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ final class AgentTabStore {
var blankChatHandler: ((String) -> NewTabPageHandler?)?
/// Each agent session's own avatar (Set Avatar… in the chat's ⋯ menu).
var agentAvatars: AgentAvatars?
/// A Chief's name and avatar as the sidebar shows them, by participant id (`chief.face`).
var chiefFace: (@MainActor (String) async -> [String: String])?
/// Opens a redeemed Run attempt's terminal, owned by an enrolled worker daemon, in the tab's pane.
var runAttach: (@MainActor (_ key: String, _ request: AgentPaneRunAttach) async throws -> Void)?
/// The New Tab page a new workspace's first tab shows, starting in the given folder.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import CmuxHomeCore
import CmuxNextCompat
import CmuxNextDaemon
import CmuxNextFloating
import CmuxNextChiefPetWiring
import CmuxNextHome
import CmuxNextHomeEngine
Expand Down Expand Up @@ -286,6 +287,25 @@ extension HomeService {
return Self.chiefFaces(chiefs: chiefs, own: directory.chiefAvatars) { services.characterPicture(of: $0) }
}

/// Chief `chief` (a participant id) as an agent chat draws a message it sent there
/// (`chief.face`, manaflow-ai/cmux#27914): the name and avatar Home and the sidebar show for it.
func agentPageFace(of chief: String, avatars: AgentAvatars) async -> [String: String] {
// The one name its tile, header and tab show (a rename being saved included): its own
// conversation's; else what a conversation or its record calls it.
let own = homeStore.rows.first { Self.soleChief(of: $0.summary.participants) == chief }
let name = own.flatMap { chiefName(of: $0.summary.id) }
?? homeStore.rows.lazy.flatMap(\.summary.participants).first { $0.id.rawValue == chief }?.displayName
?? directory.chiefs.first { $0.id == chief }?.name
// The sidebar draws the default Chief (`agent_mux`) with no id.
var picture = services.characterPicture(of: chief == DaemonHomeSource.chief.id ? nil : chief), text: String?
switch chiefFaces[chief] {
case .picture(let url): picture = url
case .text(let own): text = own
case nil: break
}
return await avatars.chiefFace(name: name, picture: picture, text: text)
}

/// `own` (each Chief's own avatar), with each other Chief in `chiefs` wearing the `picture` of its character.
static func chiefFaces(chiefs: Set<String>, own: [String: HomeChiefAvatar], picture: (String) -> URL?) -> [String: HomeChiefAvatar] {
var faces = own
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,29 @@ public final class AgentAvatars {
return nil
}

/// A Chief as an agent chat draws a message it sent there (`chief.face`, manaflow-ai/cmux#27914):
/// `name`, and its own `text` avatar, else its `picture` drawn down as `src`.
public func chiefFace(name: String?, picture: URL?, text: String?) async -> [String: String] {
var face: [String: String] = [:]
if let name = name?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty { face["name"] = name }
if let text, !text.isEmpty {
face["text"] = text
} else if let picture, let src = await self.picture(picture) {
face["src"] = src
}
return face
}

/// Picture `file` (a Chief's avatar or character still) drawn down as the agent avatars are, as a
/// data URL; nil when it cannot be read. Each still is drawn once.
func picture(_ file: URL) async -> String? {
if let known = made[file] { return known }
// task-owner: one small read and draw-down of a still; ends with it
let src = await Task.detached(priority: WorkPriority.userVisible.taskPriority) { Self.dataURL(file) }.value
if let src { made[file] = src }
return src
}

/// Set Avatar…: the open panel over `window`, then the upload. The new avatar; nil when the
/// person cancelled, or when the picture was refused (a cmux dialog over `window` says why).
public func choose(_ session: String, window: NSWindow?) async -> String? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@ struct AgentPaneAgentAvatarTests {
#expect(removed == ["s1"])
}

/// A Chief's message line asks for that Chief's face by its participant id (manaflow-ai/cmux#27914).
@Test func chiefFaceTakesOnlyAChiefIdAndAnswersThroughTheAppsHook() async {
if case .chiefFace("agent_ops") = request("chief.face", ["chief": "agent_ops"]) {} else { Issue.record("chief.face") }
for params: [String: Any] in [[:], ["chief": "ops"], ["chief": "agent_"], ["chief": "agent_a b"], ["chief": "agent_" + String(repeating: "x", count: 64)], ["chief": 7]] {
if case .unsupported = request("chief.face", params) {} else { Issue.record("\(params)") }
}
let model = AgentPaneModel(host: NoHost())
#expect((await model.respond(to: .chiefFace("agent_ops"))["value"] as? [String: Any])?.isEmpty == true, "no hook: no face")
model.onChiefFace = { $0 == "agent_ops" ? ["name": "Ops Chief", "src": "data:image/png;base64,AAAA"] : [:] }
let face = await model.respond(to: .chiefFace("agent_ops"))["value"] as? [String: Any]
#expect(face?["name"] as? String == "Ops Chief")
#expect(face?["src"] as? String == "data:image/png;base64,AAAA")
}

private actor NoHost: AgentPaneHostProviding {
func handshake(sessionId: String?) async throws -> AgentPaneHandshake { throw AgentPaneHostError.daemonStopped }
}
Expand Down
Loading
Loading