Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
6478f11
acpmux: test that two agents messaging each other without progress ar…
teamleaderleo Oct 19, 2026
78326ba
acpmux: pause two agents that keep messaging each other without progress
teamleaderleo Oct 19, 2026
aaac37d
acpmux: tests for the agent pair guard's review blockers
teamleaderleo Oct 19, 2026
d3209ce
acpmux: agent pair guard fixes from review
teamleaderleo Oct 19, 2026
00d5771
Merge remote-tracking branch 'origin/feat-cmux-next' into lead/21975
teamleaderleo Oct 20, 2026
09b6a7d
test(acpmux): agent pair guard misses cmux-subsessions messages
teamleaderleo Oct 20, 2026
35ef39d
fix(acpmux): guard cmux-subsessions messages and keep held ones owed
teamleaderleo Oct 20, 2026
80f386f
Merge remote-tracking branch 'origin/feat-cmux-next' into wt/21975
teamleaderleo Oct 20, 2026
8e35008
chore(next): PR autofix at 00d57718d07d
Oct 20, 2026
856e586
Merge remote-tracking branch 'origin/chat/agent-pingpong-guard' into …
teamleaderleo Oct 20, 2026
ca001b9
test(acpmux): a restart during a resume holds the rest again
teamleaderleo Oct 20, 2026
f78dc28
fix(acpmux): a restart during a resume keeps delivering what was held
teamleaderleo Oct 20, 2026
c49a339
feat(next): the agent pane may resume a paused agent pair of a chat i…
teamleaderleo Oct 20, 2026
3309231
acpmux: cap agent pair messages and keep held messages through a release
teamleaderleo Oct 20, 2026
01e88e8
acpmux: a dead release is not accepted, and a resent held message kee…
teamleaderleo Oct 20, 2026
87af37e
Merge remote-tracking branch 'origin/chat/agent-pingpong-guard' into …
teamleaderleo Oct 20, 2026
c944981
Merge commit 'refs/wt21975/base' into wt/21975
teamleaderleo Oct 20, 2026
7224dd5
acpmux: leave the pause row to #22309
teamleaderleo Oct 20, 2026
3374b54
Merge remote-tracking branch 'origin/feat-cmux-next' into lead/21975
teamleaderleo Oct 20, 2026
f8b5588
acpmux: a released message waits its turn, and holds keep their order
teamleaderleo Oct 20, 2026
df60fb9
test(acpmux): the sub-session pair's tenth exchange goes back to the …
teamleaderleo Oct 20, 2026
7e6da00
refactor(acpmux): keep the agent pair guard out of the over-budget files
teamleaderleo Oct 20, 2026
0648dd3
feat(agent-pane): the pane policy and page know _acpmux/agent_pair_re…
teamleaderleo Oct 20, 2026
fd79911
Merge origin/chat/agent-pingpong-guard (f8b55884c96) into lead/21975
teamleaderleo Oct 20, 2026
290a99a
fix(acpmux): a steer-only message into a paused pair is held
teamleaderleo Oct 20, 2026
91d456e
test(agent-pane): the agent_pair_resume scope cases use the runner's …
teamleaderleo Oct 20, 2026
ed07b2a
Merge branch 'feat-cmux-next' into chat/agent-pingpong-guard
teamleaderleo Oct 20, 2026
0802b88
chore(next): PR autofix at ed07b2a8de0f
Oct 20, 2026
55ce7f7
Merge branch 'feat-cmux-next' into chat/agent-pingpong-guard
teamleaderleo Oct 20, 2026
ee5b57d
chore(next): PR autofix at 55ce7f7f223f
Oct 20, 2026
93fae49
Merge branch 'feat-cmux-next' into chat/agent-pingpong-guard
teamleaderleo Oct 20, 2026
cdf7908
Merge branch 'feat-cmux-next' into chat/agent-pingpong-guard
teamleaderleo Oct 20, 2026
a4a7090
Merge branch 'feat-cmux-next' into chat/agent-pingpong-guard
teamleaderleo Oct 20, 2026
51294c4
Merge branch 'feat-cmux-next' into chat/agent-pingpong-guard
teamleaderleo Oct 20, 2026
e975ea5
Merge origin/feat-cmux-next into chat/agent-pingpong-guard
teamleaderleo Oct 21, 2026
ebdba19
acpmux agent pairs: a resume waits for the logs; god files shrink
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 @@ -45,7 +45,8 @@ nonisolated enum AcpmuxDaemonLauncher {
/// The daemon's environment: this app's, without any inherited Computer
/// Use secret, plus `childEnvironment`, non-secret Computer Use v2 state,
/// the session offer tools while suggested tasks are on
/// (``AcpmuxEnvironment/offersSessionTools``), and the launch knobs.
/// (``AcpmuxEnvironment/offersSessionTools``), the agent message runaway guard
/// off when ``AcpmuxEnvironment/agentPairGuard`` is off (else its cap), and the launch knobs.
static func spawnEnvironment(_ environment: AcpmuxEnvironment, inherited: [String: String]) -> [String: String] {
let computerUse = Set(AcpmuxEnvironment.computerUseKeys + [AcpmuxEnvironment.computerUseHostKey])
let safeInherited = inherited.filter { key, _ in
Expand All @@ -61,6 +62,12 @@ nonisolated enum AcpmuxDaemonLauncher {
if !computerUse.contains(key) || developmentCredential { variables[key] = value }
}
if environment.offersSessionTools { variables[AcpmuxEnvironment.offerToolsKey] = "1" }
if environment.agentPairGuard {
variables[AcpmuxEnvironment.agentPairCapKey] = String(max(0, environment.agentPairCap))
variables[AcpmuxEnvironment.agentPairWindowKey] = String(max(0, environment.agentPairWindowSeconds))
} else {
variables[AcpmuxEnvironment.agentPairGuardKey] = "0"
}
variables["ACPMUX_LAUNCH_LOG"] = environment.logPath
variables["ACPMUX_LOGIN_ENV"] = "1"
return variables
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ public nonisolated struct AcpmuxEnvironment: Sendable, Equatable {
/// Settings toggle `agent.suggestedTasks` is on; ``AcpmuxHost`` reads it at each daemon start, so a running
/// daemon keeps the value it started with.
public var offersSessionTools = false
/// Whether the daemon this app spawns keeps the agent message runaway guard on (acpmux
/// `hub/agent_pairs.rs`); off, it gets ``agentPairGuardKey`` `0`. The Debug Settings toggle
/// `agent.pairGuard`, read at each daemon start like ``offersSessionTools``.
public var agentPairGuard = true
/// The guard's cap and its window in seconds (`agent.pairCap`, `agent.pairWindow`); `0` turns
/// the cap off. Passed as ``agentPairCapKey`` and ``agentPairWindowKey`` while the guard is on.
public var agentPairCap = 20
public var agentPairWindowSeconds = 600
/// Whether an unsigned development daemon needs its person key on an
/// inherited descriptor. Signed builds enroll only after checking the peer.
public var passesPersonKeyAtSpawn = true
Expand All @@ -40,6 +48,11 @@ public nonisolated struct AcpmuxEnvironment: Sendable, Equatable {
public static let computerUseHostKey = "CMUX_NEXT_CUA_SOCKET_HOST_AUTH_TOKEN"
/// acpmux `agent_tools::OFFER_TOOLS_ENV`: `1` gives sessions `offer_session`.
public static let offerToolsKey = "ACPMUX_OFFER_TOOLS"
/// acpmux `hub::agent_pairs::GUARD_ENV`: `0` turns the agent message runaway guard off.
public static let agentPairGuardKey = "ACPMUX_AGENT_PAIR_GUARD"
/// acpmux `hub::agent_pairs::MAX_MESSAGES_ENV` and `WINDOW_ENV`.
public static let agentPairCapKey = "ACPMUX_AGENT_PAIR_MAX_MESSAGES"
public static let agentPairWindowKey = "ACPMUX_AGENT_PAIR_WINDOW_SECS"

/// The build-session kill switch (cx-9ettq): with either set, acpmux
/// starts no agent and the Chief host does not start. A GUI test job sets
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ nonisolated extension AcpmuxPaneMethods {
"_acpmux/offer_respond": (["sessionId", "offerId", "outcome", "childSessionId"], []),
// `subagentSessionId`: a child of that chat, which acpmux looks up in the chat's own map.
"_acpmux/subagent_stop": (["sessionId", "subagentSessionId"], []),
// `peerSessionId`: one of that chat's paused pairs, a selector only (acpmux checks the pair has the chat).
"_acpmux/agent_pair_resume": (["sessionId", "peerSessionId"], []),
"acp.session.fork": (["sessionId", "throughSeq"], []),
"acp.trust.get": (["cwd", "sessionId"], []),
"acp.trust.set": (["cwd", "level", "sessionId"], []),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import Foundation

/// The replies the relay filters itself (ad349), of ``AcpmuxPaneMethods``: read-only requests
/// whose reply reaches the pane cut to the fields it renders.
nonisolated extension AcpmuxPaneMethods {
/// The shape of a reply the relay filters itself, without relying on the daemon's redaction.
public nonisolated indirect enum ReplyShape: Sendable {
case string
case object([String: ReplyShape])
case list(ReplyShape)
/// Only the scheme of the URL in the sibling field `source` (`ssh`, `ws`, `wss`), so the
/// page learns a peer's transport without its URL (which can carry a user or a token).
case scheme(from: String)
}

/// The URL schemes a `.scheme` field may report; any other scheme is dropped.
static let reportedSchemes: Set<String> = ["ssh", "ws", "wss"]

/// Replies filtered to the fields the pane renders; everything else is dropped, at every depth.
/// `_acpmux/status`: the pane reads only `peers[].name` and each peer's `transport`, the
/// scheme of its `url` (direct.ts, after initialize; environments.ts peerKindsFromStatus).
public static let replyShapes: [String: ReplyShape] = [
"_acpmux/status": .object([
"peers": .list(.object(["name": .string, "transport": .scheme(from: "url")])),
]),
]

/// `value` cut to `shape`: nil when it does not fit (a list keeps only the items that fit).
static func filtered(_ value: Any, to shape: ReplyShape) -> Any? {
switch shape {
case .string:
return value as? String
case .scheme:
guard let scheme = (value as? String).flatMap({ URLComponents(string: $0)?.scheme?.lowercased() }),
reportedSchemes.contains(scheme) else { return nil }
return scheme
case .list(let item):
return (value as? [Any])?.compactMap { filtered($0, to: item) }
case .object(let fields):
guard let object = value as? [String: Any] else { return nil }
let kept = fields.compactMap { key, field -> (String, Any)? in
let source: String
if case .scheme(let from) = field { source = from } else { source = key }
return object[source].flatMap { filtered($0, to: field) }.map { (key, $0) }
}
// An object with none of its fields is dropped (a peer with no name renders nothing).
return kept.isEmpty ? nil : Dictionary(uniqueKeysWithValues: kept)
}
}

/// A reply to a filtered request, rebuilt with the page's id (raw JSON) and its filtered
/// result. An error keeps only its code and message.
static func filteredReply(_ object: [String: Any], shape: ReplyShape, pageID: String) -> String {
let id = (try? JSONSerialization.jsonObject(with: Data(pageID.utf8), options: [.fragmentsAllowed])) ?? NSNull()
var reply: [String: Any] = ["jsonrpc": "2.0", "id": id]
if let error = object["error"] as? [String: Any] {
reply["error"] = ["code": error["code"] as? Int ?? -32603, "message": error["message"] as? String ?? ""]
} else {
reply["result"] = object["result"].flatMap { filtered($0, to: shape) } ?? [String: Any]()
}
let data = (try? JSONSerialization.data(withJSONObject: reply, options: [.sortedKeys])) ?? Data()
return String(decoding: data, as: UTF8.self)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,67 +78,13 @@ nonisolated enum AcpmuxPaneMethods {
// for that child (claude_stdio/workflows.rs). It stops work like `_acpmux/kill`, so no
// gesture; caller params never reach Claude.
"_acpmux/subagent_stop",
// Resume a paused agent pair of a chat in the pane's scope (the pause row's Resume; acpmux
// hub/agent_pairs.rs): delivers the messages the two agents already sent and the guard held.
// `peerSessionId` only picks one of that chat's paused pairs; acpmux resumes no pair the
// chat is not in, and only for a connection that proved the person key (hub/person.rs).
"_acpmux/agent_pair_resume",
]

/// The shape of a reply the relay filters itself, without relying on the daemon's redaction.
public nonisolated indirect enum ReplyShape: Sendable {
case string
case object([String: ReplyShape])
case list(ReplyShape)
/// Only the scheme of the URL in the sibling field `source` (`ssh`, `ws`, `wss`), so the
/// page learns a peer's transport without its URL (which can carry a user or a token).
case scheme(from: String)
}

/// The URL schemes a `.scheme` field may report; any other scheme is dropped.
static let reportedSchemes: Set<String> = ["ssh", "ws", "wss"]

/// Replies filtered to the fields the pane renders; everything else is dropped, at every depth.
/// `_acpmux/status`: the pane reads only `peers[].name` and each peer's `transport`, the
/// scheme of its `url` (direct.ts, after initialize; environments.ts peerKindsFromStatus).
public static let replyShapes: [String: ReplyShape] = [
"_acpmux/status": .object([
"peers": .list(.object(["name": .string, "transport": .scheme(from: "url")])),
]),
]

/// `value` cut to `shape`: nil when it does not fit (a list keeps only the items that fit).
static func filtered(_ value: Any, to shape: ReplyShape) -> Any? {
switch shape {
case .string:
return value as? String
case .scheme:
guard let scheme = (value as? String).flatMap({ URLComponents(string: $0)?.scheme?.lowercased() }),
reportedSchemes.contains(scheme) else { return nil }
return scheme
case .list(let item):
return (value as? [Any])?.compactMap { filtered($0, to: item) }
case .object(let fields):
guard let object = value as? [String: Any] else { return nil }
let kept = fields.compactMap { key, field -> (String, Any)? in
let source: String
if case .scheme(let from) = field { source = from } else { source = key }
return object[source].flatMap { filtered($0, to: field) }.map { (key, $0) }
}
// An object with none of its fields is dropped (a peer with no name renders nothing).
return kept.isEmpty ? nil : Dictionary(uniqueKeysWithValues: kept)
}
}

/// A reply to a filtered request, rebuilt with the page's id (raw JSON) and its filtered
/// result. An error keeps only its code and message.
static func filteredReply(_ object: [String: Any], shape: ReplyShape, pageID: String) -> String {
let id = (try? JSONSerialization.jsonObject(with: Data(pageID.utf8), options: [.fragmentsAllowed])) ?? NSNull()
var reply: [String: Any] = ["jsonrpc": "2.0", "id": id]
if let error = object["error"] as? [String: Any] {
reply["error"] = ["code": error["code"] as? Int ?? -32603, "message": error["message"] as? String ?? ""]
} else {
reply["result"] = object["result"].flatMap { filtered($0, to: shape) } ?? [String: Any]()
}
let data = (try? JSONSerialization.data(withJSONObject: reply, options: [.sortedKeys])) ?? Data()
return String(decoding: data, as: UTF8.self)
}

/// Notifications (no `id`).
public static let notifications: Set<String> = ["session/cancel"]

Expand Down Expand Up @@ -232,7 +178,7 @@ nonisolated enum AcpmuxPaneMethods {
"session/prompt", "session/set_mode", "session/set_config_option", "session/set_model", "session/cancel",
"_acpmux/kill", "_acpmux/draft_get", "_acpmux/draft_set", "_acpmux/bookmark_get", "_acpmux/bookmark_set", "_acpmux/queue_remove", "_acpmux/queue_edit", "_acpmux/permission_respond", "_acpmux/permission_group_respond", "_acpmux/permission_chat_revoke",
"_acpmux/secret_submit", "_acpmux/archive", "_acpmux/markUnread", "_acpmux/side", "_acpmux/btw",
"_acpmux/offer_respond", "_acpmux/subagent_stop",
"_acpmux/offer_respond", "_acpmux/subagent_stop", "_acpmux/agent_pair_resume",
]

/// Params beside `sessionId` that name a session, which must be in the pane's scope too: New
Expand Down Expand Up @@ -304,6 +250,8 @@ nonisolated enum AcpmuxPaneMethods {
"_acpmux/permission_group_respond": .whenDecisionAllows,
// The card's Provide hands over a secret (its Decline is a click too).
"_acpmux/secret_submit": .always,
// Resume runs turns: the held messages go to both chats of the pair.
"_acpmux/agent_pair_resume": .always,
// _acpmux/permission_chat_revoke revokes: no gesture.
// _acpmux/offer_respond declines, or links the page's own new chat: no gesture (the work
// Start begins is its session/prompt, which has one).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ public actor AcpmuxHost: AgentPaneHostProviding {
/// the `agent.suggestedTasks` Debug Settings toggle), read at each daemon start: a toggle applies to the next daemon
/// this app starts, never to the one already running.
private let offersSessionTools: @Sendable () -> Bool
/// Whether the daemon keeps the agent message runaway guard on now (`AcpmuxEnvironment.agentPairGuard`,
/// the `agent.pairGuard` Debug Settings toggle), read at each daemon start like the offer tools.
private let agentPairGuard: @Sendable () -> Bool
/// The guard's cap and window in seconds now (`agent.pairCap`, `agent.pairWindow`), read with it.
private let agentPairCap: @Sendable () -> (messages: Int, windowSeconds: Int)
/// Called with the daemon socket after each successful find or start, so
/// the Computer Use helper v2 can register this daemon.
private let daemonReady: @Sendable (String) async -> Void
Expand All @@ -104,6 +109,8 @@ public actor AcpmuxHost: AgentPaneHostProviding {
self.resolveEnvironment = { environment }
self.computerUse = { [:] }
self.offersSessionTools = { environment?.offersSessionTools ?? false }
self.agentPairGuard = { environment?.agentPairGuard ?? true }
self.agentPairCap = { (environment?.agentPairCap ?? 20, environment?.agentPairWindowSeconds ?? 600) }
self.daemonReady = { _ in }
self.startsDaemons = startsDaemons
self.healReplaced = healReplaced
Expand All @@ -114,10 +121,16 @@ public actor AcpmuxHost: AgentPaneHostProviding {
public init(resolve: @escaping @Sendable () -> AcpmuxEnvironment?,
computerUse: @escaping @Sendable () -> [String: String] = { [:] },
offersSessionTools: @escaping @Sendable () -> Bool = { AgentPaneTunables.suggestedTasks.value },
agentPairGuard: @escaping @Sendable () -> Bool = { AgentPaneTunables.agentPairGuard.value },
agentPairCap: @escaping @Sendable () -> (messages: Int, windowSeconds: Int) = {
(Int(AgentPaneTunables.agentPairCap.value), Int(AgentPaneTunables.agentPairWindow.value))
},
daemonReady: (@Sendable (String) async -> Void)? = nil) {
self.resolveEnvironment = resolve
self.computerUse = computerUse
self.offersSessionTools = offersSessionTools
self.agentPairGuard = agentPairGuard
self.agentPairCap = agentPairCap
self.daemonReady = daemonReady ?? { _ in }
self.startsDaemons = true
self.healReplaced = nil
Expand Down Expand Up @@ -161,6 +174,8 @@ public actor AcpmuxHost: AgentPaneHostProviding {
}
environment.computerUse = computerUse()
environment.offersSessionTools = offersSessionTools()
environment.agentPairGuard = agentPairGuard()
(environment.agentPairCap, environment.agentPairWindowSeconds) = agentPairCap()
let daemonSocket = environment.socketPath
Self.logger.info("acpmux environment resolved executable=\(environment.executable.path, privacy: .public) home=\(environment.home.path, privacy: .public) socket=\(environment.socketPath, privacy: .public) startsDaemon=\(startsDaemon, privacy: .public)")
// task-owner: stored in inFlight and cleared when it settles; callers await its value
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
public import CmuxNextDesign
import CmuxNextPages

nonisolated extension AgentPaneTunables {
/// The runaway guard for agent-to-agent messages (acpmux `hub/agent_pairs.rs`): two agent
/// sessions that keep messaging each other without a tool call or file change in between, or
/// that keep sending the same message, are paused until the person resumes them. Off, the
/// daemon this app starts gets `ACPMUX_AGENT_PAIR_GUARD=0`. Read at each daemon start, so a
/// toggle applies to the next one.
public static let agentPairGuard = Tunable<Bool>.toggle(
"agent.pairGuard", PageTunables.section, "Pause agents messaging in a loop",
help: "Two agents that keep messaging each other without making progress are paused until you resume them.",
default: true, code: "AgentPaneTunables.agentPairGuard")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Guard ships on by default

The new agent.pairGuard setting defaults to on. This PR adds user-visible daemon behavior with more than 40 runtime lines, so the repository's high-tier rule applies: “Ship user-visible high-tier behavior behind a default-off setting.”

Default the setting and its launch behavior to off until the user opts in.

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Kept on by default. That was Leo's call for this guard: a runaway pair spends usage silently, and the pause is undone by one Resume or by the person's next prompt. The Debug Settings toggle agent.pairGuard turns it off, and agent.pairCap / agent.pairWindow (0 = off) tune the cap.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

That context makes sense. Given this guard prevents runaway agent-to-agent usage by default, while still providing an explicit Debug Settings opt-out and a direct resume path, the default-on choice is appropriate here. I’m withdrawing the default-off concern.


/// The guard's cap: two agents that exchange more than this many messages within
/// ``agentPairWindow`` with no prompt from the person are paused (`ACPMUX_AGENT_PAIR_MAX_MESSAGES`).
public static let agentPairCap = Tunable<Double>.number(
"agent.pairCap", PageTunables.section, "Agent message cap",
help: "Messages two agents may exchange without you. 0 is off.",
default: 20, range: 0...200, step: 1, unit: .count, code: "AgentPaneTunables.agentPairCap")

/// The window ``agentPairCap`` counts over (`ACPMUX_AGENT_PAIR_WINDOW_SECS`).
public static let agentPairWindow = Tunable<Double>.number(
"agent.pairWindow", PageTunables.section, "Agent message window",
help: "How long the cap counts. 0 is off.",
default: 600, range: 0...3600, step: 60, unit: .seconds, code: "AgentPaneTunables.agentPairWindow")
}
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,9 @@ public nonisolated struct AgentPaneTunables {

public static var all: [TunableDescriptor] {
[
agentPairGuard.descriptor,
agentPairCap.descriptor,
agentPairWindow.descriptor,
chatWidth.descriptor,
codexToolRuns.descriptor,
composerPlainFooter.descriptor,
Expand Down
Loading