Skip to content
Closed
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
f473c80
test(webviews): reproduce missing local peer state events
teamleaderleo Oct 28, 2026
8e9ada8
fix(webviews): consume local peer state snapshots and events
teamleaderleo Oct 28, 2026
7e9b2a9
fix(webviews): reset peer event capability before reconnect
teamleaderleo Oct 28, 2026
0e6b809
fix(webviews): a lag notice reads the peer snapshot again
teamleaderleo Oct 29, 2026
30480e3
test(webviews): reproduce a restarting peer dropped from the peer sna…
teamleaderleo Oct 30, 2026
6e26273
fix(webviews): keep a restarting peer in the peer snapshot
teamleaderleo Oct 30, 2026
61315ec
test(webviews): reproduce a listed peer the peer snapshot omits readi…
teamleaderleo Oct 30, 2026
1ffadb3
fix(webviews): a listed peer the peer snapshot omits reads not connected
teamleaderleo Oct 30, 2026
e6bfcd7
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/nx-pee…
teamleaderleo Oct 30, 2026
9f6b4f4
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/nx-pee…
teamleaderleo Oct 30, 2026
95fc3b1
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/nx-pee…
teamleaderleo Oct 30, 2026
afcea9e
test(webviews): reproduce an omitted peer losing its last-seen status
teamleaderleo Oct 30, 2026
6ef5b1c
fix(webviews): an omitted peer keeps its last-seen status
teamleaderleo Oct 30, 2026
9ddd012
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/nx-pee…
teamleaderleo Oct 30, 2026
6e624f4
test(agent-pane-policy): reproduce _acpmux/peers missing from the Rus…
teamleaderleo Oct 30, 2026
a4b6b6e
test(webviews): reproduce a stale restarting state and the snapshot o…
teamleaderleo Oct 30, 2026
bdcd371
fix(agent-pane-policy): admit _acpmux/peers in the Rust relay policy
teamleaderleo Oct 30, 2026
14c60b7
fix(webviews): a status read clears a stale restarting state; the sna…
teamleaderleo Oct 30, 2026
337df9b
test(agent-pane-policy): _acpmux/peers passes; the escaped-name refus…
teamleaderleo Oct 30, 2026
815f25f
test(webviews): reproduce status and snapshot reads landing out of order
teamleaderleo Oct 30, 2026
a01e4e5
fix(webviews): order status and snapshot reads on one clock
teamleaderleo Oct 30, 2026
820e2c7
fix(webviews): a replaced socket's replies still land; only its peer …
teamleaderleo Oct 30, 2026
ede69c2
Merge branch 'feat-cmux-next' into lane/nx-peer-events-restarting-1030
teamleaderleo Oct 30, 2026
07b0e47
test(webviews): reproduce an older status read overwriting a newer one
teamleaderleo Oct 30, 2026
886353b
fix(webviews): an older status read that lands late is dropped whole
teamleaderleo Oct 30, 2026
e18a9ca
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/nx-pee…
teamleaderleo Oct 30, 2026
6c92cc2
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/nx-pee…
teamleaderleo Oct 31, 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 @@ -70,6 +70,7 @@ nonisolated extension AcpmuxPaneMethods {
// grants nothing (the same probes acpmux runs in the background), so no gesture rule.
"_acpmux/models": (["refresh"], []),
"_acpmux/status": ([], []),
"_acpmux/peers": ([], []),
"_acpmux/draft_get": (["sessionId"], []),
"_acpmux/draft_set": (["sessionId", "text"], []),
"_acpmux/bookmark_get": (["sessionId"], []),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ nonisolated extension AcpmuxPaneMethods {
/// tell a chat on a machine that is not connected (with when it was last seen) from a deleted
/// one, and from one whose machine is still connecting (chatUnavailable.ts).
public static let replyShapes: [String: ReplyShape] = [
// The local/app connection's peer state snapshot; never transport URLs or credentials.
"_acpmux/peers": .object(["peers": .list(.object([
"name": .string, "state": .string, "lastSeen": .number, "error": .string,
]))]),
"_acpmux/status": .object([
"peers": .list(.object([
"name": .string, "transport": .scheme(from: "url"), "connected": .bool,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ nonisolated enum AcpmuxPaneMethods {
// Take Over a View Only chat (direct.ts takeOver): the chat runs the conversation from then on.
"_acpmux/watch_take_over",
// Read-only, and its reply is filtered to ``replyShapes`` (ad349).
"_acpmux/status", "_acpmux/draft_get",
"_acpmux/status", "_acpmux/peers", "_acpmux/draft_get",
// Session-scoped draft writes are kept separate from the filtered replies above.
"_acpmux/draft_set",
// The thread minimap's prompt bookmarks of a chat in the pane's scope (direct.ts
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Foundation
import Testing
@testable import CmuxNextAgentPane

@Suite struct AcpmuxPanePeerSnapshotTests {
@Test func peerSnapshotAcceptsNoParameters() {
#expect(AcpmuxPaneMethods.requests.contains("_acpmux/peers"))
#expect(!AcpmuxPaneMethods.breaksParamsRule(["method": "_acpmux/peers", "params": [:]], modeFields: nil))
for params in [["command": "pwd"], ["peer": "other"], ["url": "ssh://private"]] {
#expect(AcpmuxPaneMethods.breaksParamsRule(["method": "_acpmux/peers", "params": params], modeFields: nil))
}
}

@Test func peerSnapshotKeepsOnlyDisplayFields() throws {
let shape = try #require(AcpmuxPaneMethods.replyShapes["_acpmux/peers"])
let value: [String: Any] = ["peers": [["name": "studio-mac", "state": "offline", "lastSeen": 5,
"error": "removed", "url": "ssh://private", "token": "secret"]]]
let filtered = try #require(AcpmuxPaneMethods.filtered(value, to: shape) as? [String: Any])
let peer = try #require((filtered["peers"] as? [[String: Any]])?.first)
#expect(Set(peer.keys) == ["name", "state", "lastSeen", "error"])
#expect(peer["error"] as? String == "removed")
}
}
6 changes: 4 additions & 2 deletions cmux-tui/crates/cmux-agent-pane-policy/policy.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"_acpmux/watch", "_acpmux/sessions", "_acpmux/events", "_acpmux/attachment", "_acpmux/attach", "_acpmux/detach", "_acpmux/view", "_acpmux/typing", "_acpmux/warm",
"_acpmux/peer_snapshot", "_acpmux/peer_reconnect", "_acpmux/retry",
"_acpmux/kill", "_acpmux/prewarm", "_acpmux/harnesses", "_acpmux/models", "_acpmux/permission_respond",
"_acpmux/status", "_acpmux/draft_get", "_acpmux/draft_set", "_acpmux/bookmark_get", "_acpmux/bookmark_set", "_acpmux/btw", "_acpmux/queue_remove", "_acpmux/queue_edit",
"_acpmux/status", "_acpmux/peers", "_acpmux/draft_get", "_acpmux/draft_set", "_acpmux/bookmark_get", "_acpmux/bookmark_set", "_acpmux/btw", "_acpmux/queue_remove", "_acpmux/queue_edit",
"_acpmux/handoff_prepare", "_acpmux/handoff_get", "_acpmux/handoff_draft", "_acpmux/handoff_start",
"_acpmux/handoff_discard",
"_acpmux/permission_groups", "_acpmux/permission_group_respond", "_acpmux/permission_chat_revoke",
Expand Down Expand Up @@ -78,6 +78,7 @@
"_acpmux/harnesses": {"params": ["cwd"], "acpmux": []},
"_acpmux/models": {"params": ["refresh"], "acpmux": []},
"_acpmux/status": {"params": [], "acpmux": []},
"_acpmux/peers": {"params": [], "acpmux": []},
"_acpmux/draft_get": {"params": ["sessionId"], "acpmux": []},
"_acpmux/draft_set": {"params": ["sessionId", "text"], "acpmux": []},
"_acpmux/bookmark_get": {"params": ["sessionId"], "acpmux": []},
Expand Down Expand Up @@ -125,7 +126,8 @@
},
"reply_shapes": {
"_acpmux/status": {"object": {"peers": {"list": {"object": {"name": "string"}}}}},
"_acpmux/peer_reconnect": {"object": {"peers": {"list": {"object": {"name": "string", "state": "string", "error": "string"}}}}}
"_acpmux/peer_reconnect": {"object": {"peers": {"list": {"object": {"name": "string", "state": "string", "error": "string"}}}}},
"_acpmux/peers": {"object": {"peers": {"list": {"object": {"name": "string", "state": "string", "lastSeen": "number", "error": "string"}}}}}
},
"history_replies": ["_acpmux/attach", "_acpmux/events"],
"path_keys": ["cwd", "path", "additionalDirectories", "directory", "directories", "workingDirectory",
Expand Down
5 changes: 5 additions & 0 deletions cmux-tui/crates/cmux-agent-pane-policy/src/data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ pub struct QuestionAnswers {
#[derive(Clone, Debug, PartialEq)]
pub enum ReplyShape {
String,
/// A finite number, never a boolean (Swift's `.number`).
Number,
Object(BTreeMap<String, ReplyShape>),
List(Box<ReplyShape>),
}
Expand All @@ -62,6 +64,9 @@ impl ReplyShape {
if v.as_str() == Some("string") {
return Some(ReplyShape::String);
}
if v.as_str() == Some("number") {
return Some(ReplyShape::Number);
}
let o = v.as_object()?;
if let Some(fields) = o.get("object").and_then(Value::as_object) {
let fields = fields
Expand Down
2 changes: 2 additions & 0 deletions cmux-tui/crates/cmux-agent-pane-policy/src/reply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ use serde_json::{Map, Value, json};
pub fn filtered(value: &Value, shape: &ReplyShape) -> Option<Value> {
match shape {
ReplyShape::String => value.as_str().map(|s| Value::String(s.to_owned())),
// serde_json keeps no NaN or infinity, and a boolean is not a number.
ReplyShape::Number => value.is_number().then(|| value.clone()),
ReplyShape::List(item) => value
.as_array()
.map(|a| Value::Array(a.iter().filter_map(|v| filtered(v, item)).collect())),
Expand Down
21 changes: 15 additions & 6 deletions cmux-tui/crates/cmux-agent-pane-policy/tests/cases/frames.json
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,15 @@
"send": "unchanged"
}
},
{
"name": "request _acpmux/peers passes",
"text": "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"_acpmux/peers\",\"params\":{}}",
"first": false,
"token": "abc",
"expect": {
"send": "unchanged"
}
},
{
"name": "request _acpmux/warm passes",
"text": "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"_acpmux/warm\",\"params\":{}}",
Expand Down Expand Up @@ -643,24 +652,24 @@
}
},
{
"name": "escaped '_acpmux/peers' refused",
"text": "{\"jsonrpc\":\"2.0\",\"id\":505,\"method\":\"\\u005f\\u0061\\u0063\\u0070\\u006d\\u0075\\u0078\\u002f\\u0070\\u0065\\u0065\\u0072\\u0073\",\"params\":{}}",
"name": "escaped '_acpmux/peer_remove' refused",
"text": "{\"jsonrpc\":\"2.0\",\"id\":505,\"method\":\"\\u005f\\u0061\\u0063\\u0070\\u006d\\u0075\\u0078\\u002f\\u0070\\u0065\\u0065\\u0072\\u005f\\u0072\\u0065\\u006d\\u006f\\u0076\\u0065\",\"params\":{}}",
"first": false,
"token": null,
"expect": {
"refuse": "transport.method_refused",
"method": "_acpmux/peers",
"method": "_acpmux/peer_remove",
"id": "505"
}
},
{
"name": "notification '_acpmux/peers' refused",
"text": "{\"jsonrpc\":\"2.0\",\"method\":\"_acpmux/peers\",\"params\":{}}",
"name": "notification '_acpmux/peer_remove' refused",
"text": "{\"jsonrpc\":\"2.0\",\"method\":\"_acpmux/peer_remove\",\"params\":{}}",
"first": false,
"token": null,
"expect": {
"refuse": "transport.method_refused",
"method": "_acpmux/peers",
"method": "_acpmux/peer_remove",
"id": null
}
},
Expand Down
24 changes: 24 additions & 0 deletions cmux-tui/crates/cmux-agent-pane-policy/tests/cases/params.json
Original file line number Diff line number Diff line change
Expand Up @@ -1949,5 +1949,29 @@
},
"mode_fields": null,
"breaks": true
},
{
"name": "_acpmux/peers every known param",
"frame": {
"jsonrpc": "2.0",
"id": 1,
"method": "_acpmux/peers",
"params": {}
},
"mode_fields": null,
"breaks": false
},
{
"name": "_acpmux/peers refuses any param",
"frame": {
"jsonrpc": "2.0",
"id": 1,
"method": "_acpmux/peers",
"params": {
"peer": "other"
}
},
"mode_fields": null,
"breaks": true
}
]
47 changes: 47 additions & 0 deletions cmux-tui/crates/cmux-agent-pane-policy/tests/cases/replies.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,5 +160,52 @@
]
}
}
},
{
"name": "peer snapshot keeps each peer's name, state, lastSeen and error, never its endpoint or a boolean lastSeen",
"method": "_acpmux/peers",
"page_id": "9",
"reply": {
"jsonrpc": "2.0",
"id": 42,
"result": {
"peers": [
{
"name": "studio",
"url": "ssh://studio",
"token": "s",
"state": "restarting",
"lastSeen": 1793357781599,
"error": "daemon restarting",
"attempts": 2
},
{
"name": "box",
"state": "connected",
"lastSeen": true,
"error": null
}
],
"token": "s"
}
},
"expected": {
"jsonrpc": "2.0",
"id": 9,
"result": {
"peers": [
{
"name": "studio",
"state": "restarting",
"lastSeen": 1793357781599,
"error": "daemon restarting"
},
{
"name": "box",
"state": "connected"
}
]
}
}
}
]
7 changes: 4 additions & 3 deletions webviews/src/agent-session/acpmux/chatUnavailable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
/// One peer as `_acpmux/status` last said.
export type PeerStatus = {
connected?: boolean;
/// acpmux's link state: `connecting`, `connected`, `degraded`, `reconnecting` or `offline`.
/// acpmux's link state: `connecting`, `connected`, `degraded`, `reconnecting`, `restarting` or `offline`.
state?: string;
/// When the peer last answered, in milliseconds since the epoch.
lastSeen?: number;
Expand Down Expand Up @@ -64,10 +64,11 @@ function settled(status: PeerStatus): boolean {
}

/// A peer still on its way up: acpmux's first attempt (`connecting`), a socket whose handshake has
/// not finished (`degraded`), or a saved peer a restarted daemon has not tried yet.
/// not finished (`degraded`), a peer whose daemon announced a restart (`restarting`), or a saved
/// peer a restarted daemon has not tried yet.
function stillConnecting(status: PeerStatus): boolean {
if (settled(status)) return false;
if (status.state === "connecting" || status.state === "degraded") return true;
if (status.state === "connecting" || status.state === "degraded" || status.state === "restarting") return true;
return status.state === "reconnecting" && (status.attempts ?? 0) === 0;
}

Expand Down
Loading
Loading