@team-agent/installer 0.5.43 → 0.5.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -737,24 +737,83 @@ impl TeamOrchestratorTools {
737
737
  {
738
738
  return None;
739
739
  }
740
- if let Ok(visible) = self.get_visible_peers() {
741
- if visible.peers.iter().any(|peer| peer.as_str() == target) {
742
- return None;
743
- }
740
+ // 0.5.45 naming-addressing (design §3.6, RED-2 MCP): compute
741
+ // the visible peers ONCE and reuse for both exact-match check
742
+ // AND scope-safe candidate ranking. Peers come from
743
+ // `get_visible_peers()` (owner-team only) so sibling teams,
744
+ // host registry, and workspace reverse scans cannot leak.
745
+ let visible_peers: Vec<String> = self
746
+ .get_visible_peers()
747
+ .map(|visible| {
748
+ visible
749
+ .peers
750
+ .iter()
751
+ .map(|peer| peer.as_str().to_string())
752
+ .collect()
753
+ })
754
+ .unwrap_or_default();
755
+ if visible_peers.iter().any(|peer| peer == target) {
756
+ return None;
744
757
  }
745
- let hint = "the requested peer is not part of your team; worker-origin MCP cannot widen team scope.";
758
+ let owner_team_key = owner_team
759
+ .as_ref()
760
+ .map(TeamKey::as_str)
761
+ .unwrap_or("")
762
+ .to_string();
763
+ // Rank the typo against owner-scope peers.
764
+ let ranked = {
765
+ use crate::model::name_similarity::{rank, Candidate};
766
+ let candidates: Vec<Candidate<String>> = visible_peers
767
+ .iter()
768
+ .map(|peer| Candidate {
769
+ match_key: peer.clone(),
770
+ stable_key: peer.clone(),
771
+ payload: peer.clone(),
772
+ })
773
+ .collect();
774
+ rank(target, &candidates)
775
+ };
776
+ let candidate_values: Vec<Value> = ranked
777
+ .iter()
778
+ .map(|peer| {
779
+ serde_json::json!({
780
+ "name": peer,
781
+ "team_key": owner_team_key,
782
+ "agent_id": peer,
783
+ "advisory": true,
784
+ })
785
+ })
786
+ .collect();
787
+ let best = ranked.first().cloned();
788
+ let hint = if let Some(best) = best.as_deref() {
789
+ format!(
790
+ "the requested peer is not in your owner team; did you mean `{best}`?"
791
+ )
792
+ } else {
793
+ "the requested peer is not part of your team; worker-origin MCP cannot widen team scope.".to_string()
794
+ };
746
795
  let _ = EventLog::new(&self.workspace).write(
747
796
  "mcp.send_message_refused",
748
797
  serde_json::json!({
749
798
  "reason": "peer_not_in_scope",
750
- "sender_team_id": owner_team.as_ref().map(TeamKey::as_str).unwrap_or(""),
799
+ "sender_team_id": owner_team_key,
751
800
  "scope": "team",
752
- "hint": hint
801
+ "hint": hint,
753
802
  }),
754
803
  );
755
804
  let mut extra = serde_json::Map::new();
756
805
  extra.insert("status".to_string(), Value::String("refused".to_string()));
757
- extra.insert("hint".to_string(), Value::String(hint.to_string()));
806
+ extra.insert("hint".to_string(), Value::String(hint.clone()));
807
+ // 0.5.45 naming-addressing (design §3.6, RED-2 MCP): additive
808
+ // scope-safe suggestions inside the existing ToolError.extra
809
+ // JSON envelope — no struct/schema change, no event log
810
+ // widening. Advisory only: `isError=true` and
811
+ // reason=peer_not_in_scope stay unchanged.
812
+ extra.insert("requested_name".to_string(), Value::String(target.clone()));
813
+ if let Some(best) = best {
814
+ extra.insert("suggested_name".to_string(), Value::String(best));
815
+ }
816
+ extra.insert("candidates".to_string(), Value::Array(candidate_values));
758
817
  Some(ToolError {
759
818
  reason: ToolErrorReason::PeerNotInScope,
760
819
  exc_type: "PeerNotInScope".to_string(),
@@ -12,6 +12,12 @@
12
12
  pub mod enums;
13
13
  pub mod errors;
14
14
  pub mod ids;
15
+ // 0.5.45 naming-addressing (design §3.2/§4.1): shared pure name
16
+ // similarity ranking helper. crate-private consumer set = cli/emit.rs
17
+ // (subcommand hint), cli/named_address.rs (typo candidates),
18
+ // cli/send.rs (positional adapter), mcp_server/tools.rs (owner-scoped
19
+ // peer suggestions). Never a routing authority — advisory only.
20
+ pub(crate) mod name_similarity;
15
21
  pub mod paths;
16
22
  pub mod permissions;
17
23
  pub mod routing;
@@ -0,0 +1,266 @@
1
+ //! 0.5.45 naming-addressing (design §3.2/§4.1) — shared pure name
2
+ //! similarity ranking helper.
3
+ //!
4
+ //! **Pure**: no filesystem reads, no state loads, no terminal
5
+ //! multiplexer calls, no message dispatch. The RED-6 governance guard
6
+ //! scans this file for the forbidden import tokens and rejects any
7
+ //! occurrence.
8
+ //!
9
+ //! **Single source of truth for name-similarity ranking**: consumed by
10
+ //! - `cli/emit.rs::nearest_subcommand` (unknown top-level subcommand hint),
11
+ //! - `cli/named_address.rs` (bare/`--to-name` typo candidates),
12
+ //! - `cli/send.rs` positional adapter (unknown short id in selected team),
13
+ //! - `mcp_server/tools.rs` owner-scoped peer suggestions.
14
+ //!
15
+ //! **Never a routing authority**: the returned ranking is *advisory
16
+ //! only*. Callers must still exit 1 / `isError=true` / refuse the
17
+ //! request on their original reason; the ranked payload only
18
+ //! populates the additive `requested_name` / `suggested_name` /
19
+ //! `candidates` fields that get echoed back to the user.
20
+ //!
21
+ //! Ranking rules (design §3.2, byte-locked by RED-6):
22
+ //! 1. Comparison keys are trimmed and ASCII-lowercased.
23
+ //! 2. Candidates whose `match_key` prefixes the request outrank
24
+ //! lower-distance non-prefix candidates.
25
+ //! 3. Otherwise sort by Levenshtein distance.
26
+ //! 4. Threshold widens with request length: `0..=3 → 1`, `4..=6 →
27
+ //! 2`, longer → `3`. Nothing beyond the threshold is returned —
28
+ //! no "best guess for a far name".
29
+ //! 5. Sort order: `prefix_miss` (bool), `distance`, `stable_key`.
30
+ //! 6. Deduplicate on `stable_key`, then cap at 3 results.
31
+ //! 7. Canonical spelling comes from the candidate `name` payload
32
+ //! (not the lowercased comparison key) so case is preserved.
33
+
34
+ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
35
+
36
+ use std::collections::BTreeSet;
37
+
38
+ /// A candidate to rank. `T` is a caller-owned payload returned
39
+ /// verbatim in the ranked output — callers use it to keep whatever
40
+ /// side data (team_key, workspace, etc.) they need to reconstruct
41
+ /// their advisory JSON envelope.
42
+ #[derive(Debug, Clone)]
43
+ pub struct Candidate<T> {
44
+ /// Comparison key. Callers pass the raw canonical name; this
45
+ /// function trims + lowercases before comparing.
46
+ pub match_key: String,
47
+ /// Stable tie-break key. Typically the same as `match_key`, but
48
+ /// callers with cross-team or cross-workspace candidates use a
49
+ /// composite (`team/agent`) so ordering is deterministic across
50
+ /// otherwise-tied entries.
51
+ pub stable_key: String,
52
+ /// Caller-owned payload returned verbatim.
53
+ pub payload: T,
54
+ }
55
+
56
+ /// Threshold for accepting a candidate given the request length.
57
+ /// Byte-locked by RED-6: short requests get 1, medium 2, longer 3.
58
+ fn threshold_for_len(len: usize) -> usize {
59
+ match len {
60
+ 0..=3 => 1,
61
+ 4..=6 => 2,
62
+ _ => 3,
63
+ }
64
+ }
65
+
66
+ /// Rank `candidates` against `request`. Returns up to 3 payloads,
67
+ /// most-similar-first. `<request>` casing does not affect matching,
68
+ /// only comparison; the caller-supplied payload (which carries the
69
+ /// canonical `name`) is what surfaces to the user.
70
+ ///
71
+ /// Empty return means "no acceptable candidate" — do NOT re-rank or
72
+ /// synthesize a best-guess elsewhere.
73
+ pub fn rank<T: Clone>(request: &str, candidates: &[Candidate<T>]) -> Vec<T> {
74
+ let request_key = normalize(request);
75
+ if request_key.is_empty() {
76
+ return Vec::new();
77
+ }
78
+ let threshold = threshold_for_len(request_key.chars().count());
79
+ let mut seen: BTreeSet<String> = BTreeSet::new();
80
+ let mut scored: Vec<(bool, usize, String, T)> = Vec::new();
81
+ for candidate in candidates {
82
+ if !seen.insert(candidate.stable_key.clone()) {
83
+ continue;
84
+ }
85
+ let match_key = normalize(&candidate.match_key);
86
+ if match_key.is_empty() {
87
+ continue;
88
+ }
89
+ let distance = levenshtein(&request_key, &match_key);
90
+ // Prefix hit means "request is a prefix of candidate" OR
91
+ // "candidate is a prefix of request" — either direction
92
+ // beats a same-distance edit anywhere in the middle.
93
+ let is_prefix = match_key.starts_with(&request_key) || request_key.starts_with(&match_key);
94
+ // Prefix candidates skip the distance threshold (a 1-char
95
+ // prefix miss with distance 5 is still a legitimate
96
+ // completion hint); non-prefix candidates must clear the
97
+ // threshold.
98
+ if !is_prefix && distance > threshold {
99
+ continue;
100
+ }
101
+ // Prefix-miss is the primary sort key; sorting bools puts
102
+ // false (prefix hit) before true (prefix miss).
103
+ scored.push((
104
+ !is_prefix,
105
+ distance,
106
+ candidate.stable_key.clone(),
107
+ candidate.payload.clone(),
108
+ ));
109
+ }
110
+ scored.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2)));
111
+ scored.into_iter().take(3).map(|(_, _, _, p)| p).collect()
112
+ }
113
+
114
+ /// Convenience wrapper for the common case where callers only need
115
+ /// the single best canonical name (a `String`), not full payloads.
116
+ /// Returns `None` when no candidate clears the threshold.
117
+ pub fn best_name<'a, S: AsRef<str>>(request: &str, candidates: &'a [S]) -> Option<&'a str> {
118
+ let payloads: Vec<Candidate<&'a str>> = candidates
119
+ .iter()
120
+ .map(|c| {
121
+ let name = c.as_ref();
122
+ Candidate {
123
+ match_key: name.to_string(),
124
+ stable_key: name.to_string(),
125
+ payload: name,
126
+ }
127
+ })
128
+ .collect();
129
+ rank(request, &payloads).into_iter().next()
130
+ }
131
+
132
+ fn normalize(input: &str) -> String {
133
+ input.trim().to_ascii_lowercase()
134
+ }
135
+
136
+ /// Standard Levenshtein edit distance. Pure, no dependencies.
137
+ /// Compared strings must already be normalized (trim + lowercase) by
138
+ /// the caller (`rank` does this internally).
139
+ pub fn levenshtein(a: &str, b: &str) -> usize {
140
+ let a: Vec<char> = a.chars().collect();
141
+ let b: Vec<char> = b.chars().collect();
142
+ let mut prev: Vec<usize> = (0..=b.len()).collect();
143
+ let mut curr = vec![0usize; b.len() + 1];
144
+ for (i, &ca) in a.iter().enumerate() {
145
+ curr[0] = i + 1;
146
+ for (j, &cb) in b.iter().enumerate() {
147
+ let cost = if ca == cb { 0 } else { 1 };
148
+ curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
149
+ }
150
+ std::mem::swap(&mut prev, &mut curr);
151
+ }
152
+ prev[b.len()]
153
+ }
154
+
155
+ #[cfg(test)]
156
+ mod tests {
157
+ use super::*;
158
+
159
+ fn c(name: &str) -> Candidate<String> {
160
+ Candidate {
161
+ match_key: name.to_string(),
162
+ stable_key: name.to_string(),
163
+ payload: name.to_string(),
164
+ }
165
+ }
166
+
167
+ #[test]
168
+ fn levenshtein_basic() {
169
+ assert_eq!(levenshtein("kitten", "sitting"), 3);
170
+ assert_eq!(levenshtein("", ""), 0);
171
+ assert_eq!(levenshtein("abc", "abc"), 0);
172
+ assert_eq!(levenshtein("abc", "abd"), 1);
173
+ }
174
+
175
+ #[test]
176
+ fn prefix_hit_beats_lower_distance_non_prefix() {
177
+ // "stat" is a prefix of "status" (distance 2) but "slat" is
178
+ // distance 1 (mid-word substitution). Prefix wins.
179
+ let out = rank("stat", &[c("slat"), c("status")]);
180
+ assert_eq!(out, vec!["status".to_string(), "slat".to_string()]);
181
+ }
182
+
183
+ #[test]
184
+ fn distance_orders_non_prefix() {
185
+ let out = rank("btea", &[c("bota"), c("beta")]);
186
+ assert_eq!(out[0], "beta"); // 1 edit
187
+ assert_eq!(out[1], "bota"); // 2 edits
188
+ }
189
+
190
+ #[test]
191
+ fn stable_key_decides_tie() {
192
+ // Both "beta" and "bota" are distance 2 from "bata".
193
+ let out = rank("bata", &[c("bota"), c("beta")]);
194
+ assert_eq!(out, vec!["beta".to_string(), "bota".to_string()]);
195
+ }
196
+
197
+ #[test]
198
+ fn caps_at_three() {
199
+ let out = rank("aaaa", &[c("aaab"), c("aaac"), c("aaad"), c("aaae")]);
200
+ assert_eq!(out.len(), 3);
201
+ assert_eq!(out, vec!["aaab", "aaac", "aaad"]);
202
+ }
203
+
204
+ #[test]
205
+ fn case_preserved_in_payload() {
206
+ // "BTEA" normalized matches "Beta" (distance 1); the
207
+ // canonical payload preserves original casing.
208
+ let out = rank("BTEA", &[c("Beta")]);
209
+ assert_eq!(out, vec!["Beta".to_string()]);
210
+ }
211
+
212
+ #[test]
213
+ fn far_request_returns_empty() {
214
+ let out = rank("zzzzzz", &[c("beta")]);
215
+ assert!(out.is_empty());
216
+ }
217
+
218
+ #[test]
219
+ fn threshold_widens_with_length() {
220
+ assert_eq!(threshold_for_len(0), 1);
221
+ assert_eq!(threshold_for_len(3), 1);
222
+ assert_eq!(threshold_for_len(4), 2);
223
+ assert_eq!(threshold_for_len(6), 2);
224
+ assert_eq!(threshold_for_len(7), 3);
225
+ assert_eq!(threshold_for_len(20), 3);
226
+ }
227
+
228
+ #[test]
229
+ fn empty_request_returns_empty() {
230
+ let out = rank("", &[c("beta")]);
231
+ assert!(out.is_empty());
232
+ }
233
+
234
+ #[test]
235
+ fn dedup_on_stable_key() {
236
+ let out = rank(
237
+ "beta",
238
+ &[
239
+ c("beta"),
240
+ Candidate {
241
+ match_key: "beta".to_string(),
242
+ stable_key: "beta".to_string(),
243
+ payload: "beta-dup".to_string(),
244
+ },
245
+ ],
246
+ );
247
+ assert_eq!(out, vec!["beta".to_string()]);
248
+ }
249
+
250
+ #[test]
251
+ fn best_name_convenience() {
252
+ assert_eq!(best_name("btea", &["beta", "bota"]), Some("beta"));
253
+ assert_eq!(best_name("zzzzzz", &["beta"]), None);
254
+ }
255
+
256
+ #[test]
257
+ fn statu_still_maps_to_status() {
258
+ // Byte-lock the existing emit.rs behavior: `statu` → `status`
259
+ // with the shared helper.
260
+ let out = rank(
261
+ "statu",
262
+ &[c("status"), c("send"), c("start"), c("restart")],
263
+ );
264
+ assert_eq!(out[0], "status");
265
+ }
266
+ }
@@ -99,6 +99,7 @@ mod unix_impl {
99
99
  //! `cli/mod.rs`, `coordinator/backoff.rs`, `mcp_server/wire.rs`,
100
100
  //! `lifecycle/restart/agent.rs`) with zero behavioral drift.
101
101
  use super::*;
102
+ use std::process::Command;
102
103
 
103
104
  pub fn current_parent_pid() -> Option<u32> {
104
105
  // Byte-equivalent to `mcp_server/wire.rs:319` and
@@ -150,8 +151,38 @@ mod unix_impl {
150
151
  Ok(Vec::new())
151
152
  }
152
153
 
153
- pub fn process_tree(_root: u32) -> Result<Vec<u32>, io::Error> {
154
- Ok(Vec::new())
154
+ pub fn process_tree(root: u32) -> Result<Vec<u32>, io::Error> {
155
+ let output = Command::new("ps").args(["-axo", "pid=,ppid="]).output()?;
156
+ if !output.status.success() {
157
+ return Err(io::Error::new(
158
+ io::ErrorKind::Other,
159
+ "ps_parent exited unsuccessfully",
160
+ ));
161
+ }
162
+ let pairs = String::from_utf8_lossy(&output.stdout)
163
+ .lines()
164
+ .filter_map(|line| {
165
+ let mut parts = line.split_whitespace();
166
+ let pid = parts.next()?.parse::<u32>().ok()?;
167
+ let ppid = parts.next()?.parse::<u32>().ok()?;
168
+ Some((pid, ppid))
169
+ })
170
+ .collect::<Vec<_>>();
171
+ let mut out = Vec::new();
172
+ collect_child_pids(root, &pairs, &mut out);
173
+ out.push(root);
174
+ out.sort_unstable();
175
+ out.dedup();
176
+ Ok(out)
177
+ }
178
+
179
+ fn collect_child_pids(parent: u32, pairs: &[(u32, u32)], out: &mut Vec<u32>) {
180
+ for (pid, ppid) in pairs {
181
+ if *ppid == parent && !out.contains(pid) {
182
+ out.push(*pid);
183
+ collect_child_pids(*pid, pairs, out);
184
+ }
185
+ }
155
186
  }
156
187
 
157
188
  /// Send a SIGTERM (`TerminateGraceful`) or SIGKILL
@@ -1130,6 +1130,34 @@ pub fn load_runtime_state(workspace: &Path) -> Result<Value, StateError> {
1130
1130
  Ok(state)
1131
1131
  }
1132
1132
 
1133
+ pub(crate) fn load_runtime_state_without_migrations(workspace: &Path) -> Result<Value, StateError> {
1134
+ let path = runtime_state_path(workspace);
1135
+ if !path.exists() {
1136
+ return Ok(
1137
+ json!({"agents": {}, "tasks": [], "session_name": null, "active_team_key": null}),
1138
+ );
1139
+ }
1140
+ let text = std::fs::read_to_string(&path)?;
1141
+ let mut state: Value = serde_json::from_str(&text)?;
1142
+ normalize_agent_session_state(&mut state);
1143
+ Ok(state)
1144
+ }
1145
+
1146
+ pub(crate) fn save_runtime_state_without_migrations(
1147
+ workspace: &Path,
1148
+ state: &Value,
1149
+ ) -> Result<(), StateError> {
1150
+ let path = runtime_state_path(workspace);
1151
+ let _lock = RuntimeLock::acquire(workspace, "state-save-raw", 2.0)?;
1152
+ if let Some(parent) = path.parent() {
1153
+ std::fs::create_dir_all(parent)?;
1154
+ }
1155
+ let payload = serde_json::to_string_pretty(state)?;
1156
+ std::fs::write(&path, payload.as_bytes())?;
1157
+ cache_set(&path, state);
1158
+ Ok(())
1159
+ }
1160
+
1133
1161
  #[cfg(test)]
1134
1162
  mod tests {
1135
1163
  #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
@@ -47,6 +47,7 @@ use super::persist::{
47
47
  save_runtime_state_with_lifecycle_topology_authority_and_capture_backfill_skip as helper_write_root_with_lifecycle_topology_authority_and_capture_backfill_skip,
48
48
  save_runtime_state_with_team_tombstone_lifecycle_topology_authority as helper_write_root_with_team_tombstone_lifecycle_topology_authority,
49
49
  save_runtime_state_with_team_tombstoned_agents as helper_write_root_with_team_tombstoned_agents,
50
+ save_runtime_state_without_migrations as helper_write_root_without_migrations,
50
51
  };
51
52
  use super::projection::{
52
53
  resolve_team_scoped_state as helper_resolve_team_scoped,
@@ -173,6 +174,9 @@ pub enum StateWriteIntent<'a> {
173
174
  ClaimLeader {
174
175
  team_key: &'a str,
175
176
  },
177
+ LeaderBindingRestoreNonTargetTeams {
178
+ target_team_key: &'a str,
179
+ },
176
180
  LeaderStartBinding {
177
181
  team_key: &'a str,
178
182
  transport_kind: &'a str,
@@ -325,6 +329,9 @@ fn route_direct(
325
329
  // scoped preserve-claim-fields variant at :1702 uses the team-tombstoned
326
330
  // agents helper.
327
331
  StateWriteIntent::ClaimLeader { .. } => helper_write_root(workspace, state),
332
+ StateWriteIntent::LeaderBindingRestoreNonTargetTeams { .. } => {
333
+ helper_write_root_without_migrations(workspace, state)
334
+ }
328
335
  // LeaderStartBinding -> managed/exec/external all root-save at
329
336
  // leader/start.rs:795/903/946.
330
337
  StateWriteIntent::LeaderStartBinding { .. } => helper_write_root(workspace, state),
@@ -457,6 +457,19 @@ fn session_exists_on_endpoint(endpoint: &str, session: &str) -> bool {
457
457
  session_exists_on_endpoint_checked(endpoint, session).unwrap_or(false)
458
458
  }
459
459
 
460
+ pub(crate) fn team_session_ready_on_endpoint(endpoint: &str, session: &str) -> Option<bool> {
461
+ if endpoint.is_empty() || session.is_empty() {
462
+ return None;
463
+ }
464
+ let backend = crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint);
465
+ let targets = backend.list_targets().ok()?;
466
+ Some(
467
+ targets
468
+ .iter()
469
+ .any(|target| target.session.as_str() == session),
470
+ )
471
+ }
472
+
460
473
  fn session_exists_on_endpoint_checked(endpoint: &str, session: &str) -> Option<bool> {
461
474
  crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint)
462
475
  .has_session(&SessionName::new(session.to_string()))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.43",
3
+ "version": "0.5.45",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -20,9 +20,9 @@
20
20
  "team-agent-installer": "npm/install.mjs"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@team-agent/cli-darwin-arm64": "0.5.43",
24
- "@team-agent/cli-darwin-x64": "0.5.43",
25
- "@team-agent/cli-linux-x64": "0.5.43"
23
+ "@team-agent/cli-darwin-arm64": "0.5.45",
24
+ "@team-agent/cli-darwin-x64": "0.5.45",
25
+ "@team-agent/cli-linux-x64": "0.5.45"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",