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
53 changes: 50 additions & 3 deletions crates/shim/src/terminal_send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,28 @@ fn resolve_targets(
.is_some_and(|p| p.eq_ignore_ascii_case(token))
})
.collect();
let single = agents
.iter()
.find(|s| s.id.0.as_ref() == token || handle(nicknames, s) == *token);
// An exact id names one session; a nickname can name several, since
// nicknames repeat across restarts (a ghost row and its live successor
// were both `mellow-weevil` on 2026-11-06), and sending to the first could
// hand the message to the ghost.
let single = match agents.iter().find(|s| s.id.0.as_ref() == token) {
Some(session) => Some(session),
None => {
let named: Vec<_> = agents
.iter()
.filter(|s| handle(nicknames, s) == *token)
.collect();
if named.len() > 1 {
let ids: Vec<&str> = named.iter().map(|s| s.id.0.as_ref()).collect();
return Err(format!(
"`{token}` names {} sessions ({}); use one's id",
named.len(),
ids.join(", ")
));
}
named.into_iter().next()
}
};
match (team.is_empty(), single) {
(false, Some(_)) => {
return Err(format!(
Expand Down Expand Up @@ -329,6 +348,34 @@ mod tests {
assert!(resolve_targets(&sessions, &[], &named("catapp")).is_err());
}

#[test]
fn a_nickname_two_sessions_share_is_refused_with_their_ids() {
let sessions = vec![
session("ghost-0000-0000", None, None),
session("live-1111-1111", None, None),
];
let nick = |id: &str| {
Arc::new(SessionNickname {
id: muster_entities::SessionNicknameId(Arc::from(id)),
nickname: "mellow-weevil".into(),
})
};
let nicknames = vec![nick("ghost-0000-0000"), nick("live-1111-1111")];
let err = resolve_targets(&sessions, &nicknames, &named("mellow-weevil")).unwrap_err();
assert!(
err.contains("ghost-0000-0000") && err.contains("live-1111-1111"),
"{err}"
);
// Either id still reaches its session.
let one = resolve_targets(&sessions, &nicknames, &named("live-1111-1111")).unwrap();
assert_eq!(one.len(), 1);
assert_eq!(one[0].id.0.as_ref(), "live-1111-1111");
// With only one session on that nickname, it resolves to that one.
let one = resolve_targets(&sessions, &nicknames[1..], &named("mellow-weevil")).unwrap();
assert_eq!(one.len(), 1);
assert_eq!(one[0].id.0.as_ref(), "live-1111-1111");
}

#[test]
fn a_terminal_cant_be_addressed() {
assert!(resolve_targets(&roster(), &[], &named("cccccccc-1")).is_err());
Expand Down
6 changes: 6 additions & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,12 @@ Merged and needing a live run: MUS-36 and MUS-37.
message, marks it shown (`claimShown`, MUS-90) and returns its text, which was thrown away; the
second message's own push then finds it already shown and skips it. Whether it fails depends on
whether the second push lands before that drain. Done: what the drain returns is prompted too.
- [x] **MUS-109: `muster-shim tell` refuses a nickname two sessions share.** From the watchman
(mellow-weevil), 2026-11-06, after the same bug in Marshal's port (marshal#168). Nicknames
repeat across restarts, and today a ghost row and the live watchman were both mellow-weevil, so
`resolve_targets`' first match could hand `rally digest`'s `tell mellow-weevil` to the ghost and
report it sent. Done: an exact id still names one session; a nickname that matches several is
refused with their ids, as an ambiguous prefix already was.
- [x] **MUS-112: The TypeScript plugins reach a TLS daemon on their own host.** From the README audit
(2026-11-06): with TLS on, a plain loopback connection must present the local token (MUS-7),
and no plugin read it, so Pi, opencode and Prime Agent worked only with TLS off. Done: each
Expand Down
Loading