@team-agent/installer 0.3.1 → 0.3.2

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.
@@ -14,7 +14,7 @@ use std::path::Path;
14
14
  use serde_json::{json, Map, Value};
15
15
 
16
16
  use super::StateError;
17
- use crate::state::persist::{load_runtime_state, save_runtime_state};
17
+ use crate::state::persist::{load_runtime_state, save_runtime_state_with_deleted_agents};
18
18
 
19
19
  /// `team_state_key`(`state.py:93`):从 team_dir(.name)/spec_path(.parent.name)派生 team key,
20
20
  /// 跳过 `.team`/`runtime`;兜底 `session_name` 或 `"current"`。
@@ -43,6 +43,122 @@ pub fn team_state_key(state: &Value) -> String {
43
43
  .map_or_else(|| "current".to_string(), str::to_string)
44
44
  }
45
45
 
46
+ #[derive(Debug, Clone, PartialEq, Eq)]
47
+ pub enum OwnerTeamResolution {
48
+ Canonical(String),
49
+ LegacyAlias { requested: String, canonical: String },
50
+ Unresolved { requested: String },
51
+ Ambiguous { requested: String, matches: Vec<String> },
52
+ }
53
+
54
+ impl OwnerTeamResolution {
55
+ pub fn canonical_key(&self) -> Option<&str> {
56
+ match self {
57
+ OwnerTeamResolution::Canonical(key)
58
+ | OwnerTeamResolution::LegacyAlias { canonical: key, .. } => Some(key),
59
+ OwnerTeamResolution::Unresolved { .. } | OwnerTeamResolution::Ambiguous { .. } => None,
60
+ }
61
+ }
62
+ }
63
+
64
+ pub fn resolve_owner_team_id(state: &Value, owner_team_id: &str) -> OwnerTeamResolution {
65
+ let requested = owner_team_id.trim();
66
+ if requested.is_empty() {
67
+ return OwnerTeamResolution::Unresolved { requested: owner_team_id.to_string() };
68
+ }
69
+ let teams = state.get("teams").and_then(Value::as_object);
70
+ if teams.is_some_and(|teams| teams.contains_key(requested)) {
71
+ if has_top_level_runtime_content(state) {
72
+ return OwnerTeamResolution::Canonical(requested.to_string());
73
+ }
74
+ }
75
+ if teams.is_none_or(Map::is_empty) {
76
+ let active = state.get("active_team_key").and_then(Value::as_str).unwrap_or("");
77
+ let derived = team_state_key(state);
78
+ if active == requested || derived == requested {
79
+ return OwnerTeamResolution::Canonical(requested.to_string());
80
+ }
81
+ if !active.is_empty() {
82
+ return OwnerTeamResolution::LegacyAlias {
83
+ requested: requested.to_string(),
84
+ canonical: active.to_string(),
85
+ };
86
+ }
87
+ if derived != "current" {
88
+ return OwnerTeamResolution::LegacyAlias {
89
+ requested: requested.to_string(),
90
+ canonical: derived,
91
+ };
92
+ }
93
+ return OwnerTeamResolution::Canonical(requested.to_string());
94
+ }
95
+ let Some(teams) = teams else {
96
+ return OwnerTeamResolution::Unresolved { requested: requested.to_string() };
97
+ };
98
+ let mut matches = Vec::new();
99
+ for (key, entry) in teams {
100
+ if legacy_owner_team_aliases(entry).any(|alias| alias == requested) {
101
+ matches.push(key.clone());
102
+ }
103
+ }
104
+ matches.sort();
105
+ matches.dedup();
106
+ match matches.len() {
107
+ 0 => OwnerTeamResolution::Unresolved { requested: requested.to_string() },
108
+ 1 => OwnerTeamResolution::LegacyAlias {
109
+ requested: requested.to_string(),
110
+ canonical: matches.remove(0),
111
+ },
112
+ _ => OwnerTeamResolution::Ambiguous { requested: requested.to_string(), matches },
113
+ }
114
+ }
115
+
116
+ fn has_top_level_runtime_content(state: &Value) -> bool {
117
+ [
118
+ "session_name",
119
+ "team_dir",
120
+ "spec_path",
121
+ "workspace",
122
+ "agents",
123
+ "tasks",
124
+ "leader_receiver",
125
+ "team_owner",
126
+ ]
127
+ .into_iter()
128
+ .any(|key| state.get(key).is_some_and(super::json_truthy))
129
+ }
130
+
131
+ fn legacy_owner_team_aliases(entry: &Value) -> impl Iterator<Item = String> + '_ {
132
+ let scalar_paths = [
133
+ "/team/name",
134
+ "/team/id",
135
+ "/name",
136
+ "/team_name",
137
+ "/team_id",
138
+ "/spec_name",
139
+ "/legacy_owner_team_id",
140
+ "/legacy_team_id",
141
+ "/legacy_team_name",
142
+ "/legacy_alias",
143
+ ];
144
+ let list_paths = ["/legacy_aliases", "/legacy_team_aliases", "/legacy_owner_team_ids", "/aliases"];
145
+ let scalars = scalar_paths
146
+ .into_iter()
147
+ .filter_map(|path| entry.pointer(path).and_then(Value::as_str));
148
+ let lists = list_paths.into_iter().flat_map(|path| {
149
+ entry
150
+ .pointer(path)
151
+ .and_then(Value::as_array)
152
+ .into_iter()
153
+ .flatten()
154
+ .filter_map(Value::as_str)
155
+ });
156
+ scalars
157
+ .chain(lists)
158
+ .filter(|alias| !alias.is_empty())
159
+ .map(str::to_string)
160
+ }
161
+
46
162
  /// `compact_team_state`(`state.py:105`):剔除 `teams`(team entry 不嵌套全量 teams),保序。
47
163
  pub fn compact_team_state(state: &Value) -> Value {
48
164
  match state.as_object() {
@@ -339,6 +455,14 @@ pub fn resolve_team_scoped_state(
339
455
  /// 纯 `save_runtime_state`(字节等价);多 team 时把本 team 落到 `teams[target_key]=compact(...)`,顶层
340
456
  /// 视图按 golden 的 `existing_primary_key` 逻辑择 incoming/existing。§10:无 unwrap/panic。
341
457
  pub fn save_team_scoped_state(workspace: &Path, team_state: &Value) -> Result<(), StateError> {
458
+ save_team_scoped_state_with_deleted_agents(workspace, team_state, &[])
459
+ }
460
+
461
+ pub(crate) fn save_team_scoped_state_with_deleted_agents(
462
+ workspace: &Path,
463
+ team_state: &Value,
464
+ deleted_agent_ids: &[&str],
465
+ ) -> Result<(), StateError> {
342
466
  let target_key = team_state_key(team_state);
343
467
  let existing = load_runtime_state(workspace)?;
344
468
  // existing_primary_key = team_state_key(existing) if existing.get("session_name") else None
@@ -367,7 +491,7 @@ pub fn save_team_scoped_state(workspace: &Path, team_state: &Value) -> Result<()
367
491
  // not existing_teams and existing_primary_key == target_key → 纯 save(剔 teams)。
368
492
  if existing_teams.is_empty() && existing_primary_key.as_deref() == Some(target_key.as_str()) {
369
493
  let merged = compact_team_state(team_state);
370
- return save_runtime_state(workspace, &merged);
494
+ return save_runtime_state_with_deleted_agents(workspace, &merged, deleted_agent_ids);
371
495
  }
372
496
  // teams = deepcopy(incoming_teams or existing_teams)
373
497
  let mut teams = match incoming_teams {
@@ -387,7 +511,7 @@ pub fn save_team_scoped_state(workspace: &Path, team_state: &Value) -> Result<()
387
511
  if merged.get("teams").and_then(Value::as_object).is_some_and(Map::is_empty) {
388
512
  merged.remove("teams");
389
513
  }
390
- save_runtime_state(workspace, &Value::Object(merged))
514
+ save_runtime_state_with_deleted_agents(workspace, &Value::Object(merged), deleted_agent_ids)
391
515
  }
392
516
 
393
517
  // ---- helpers ----
@@ -494,6 +494,14 @@ fn submit_verification_for_key(key: Key) -> SubmitVerification {
494
494
  }
495
495
  }
496
496
 
497
+ fn capture_has_pasted_content_prompt(text: &str) -> bool {
498
+ let lower = text.to_ascii_lowercase();
499
+ lower.contains("pasted content") || lower.contains("pasted text")
500
+ }
501
+
502
+ const PASTED_CONTENT_APPEAR_POLLS: u32 = 5;
503
+ const PASTED_CONTENT_SUBMIT_ATTEMPTS: u32 = 3;
504
+
497
505
  fn shell_command(argv: &[String], cwd: &Path, env: &BTreeMap<String, String>) -> String {
498
506
  let mut parts = Vec::new();
499
507
  parts.push("cd".to_string());
@@ -579,7 +587,40 @@ impl Transport for TmuxBackend {
579
587
  self.run_inject_stage(&argv, stage)?;
580
588
  }
581
589
  }
590
+ let mut saw_pasted_prompt = false;
591
+ for _ in 0..PASTED_CONTENT_APPEAR_POLLS {
592
+ let captured = self.capture(target, CaptureRange::Tail(80))?;
593
+ if capture_has_pasted_content_prompt(&captured.text) {
594
+ saw_pasted_prompt = true;
595
+ break;
596
+ }
597
+ std::thread::sleep(Duration::from_millis(25));
598
+ }
582
599
  let submit_argv = tmux_send_keys_argv(&pane, &[submit]);
600
+ if saw_pasted_prompt {
601
+ let mut attempts = 0;
602
+ let mut cleared = false;
603
+ for _ in 0..PASTED_CONTENT_SUBMIT_ATTEMPTS {
604
+ attempts += 1;
605
+ self.run_inject_stage(&submit_argv, InjectStage::Submit)?;
606
+ let captured = self.capture(target, CaptureRange::Tail(80))?;
607
+ if !capture_has_pasted_content_prompt(&captured.text) {
608
+ cleared = true;
609
+ break;
610
+ }
611
+ }
612
+ return Ok(InjectReport {
613
+ stage_reached: InjectStage::Submit,
614
+ inject_verification: InjectVerification::CaptureContainsNewPastedContentPrompt,
615
+ submit_verification: if cleared {
616
+ SubmitVerification::PastedContentPromptAbsentAfterSubmit
617
+ } else {
618
+ submit_verification_for_key(submit)
619
+ },
620
+ turn_verification: TurnVerification::NotYetObserved,
621
+ attempts,
622
+ });
623
+ }
583
624
  self.run_inject_stage(&submit_argv, InjectStage::Submit)?;
584
625
  }
585
626
  }
@@ -674,7 +715,10 @@ impl Transport for TmuxBackend {
674
715
  }
675
716
  let mut panes = Vec::new();
676
717
  for line in output.stdout.lines().filter(|line| !line.is_empty()) {
677
- if let Some(pane) = parse_pane_info_line(line) {
718
+ if let Some(mut pane) = parse_pane_info_line(line) {
719
+ if pane.pane_pid.is_none() {
720
+ pane.pane_pid = query_pane_pid(self, &pane.pane_id)?;
721
+ }
678
722
  panes.push(pane);
679
723
  }
680
724
  }
@@ -737,22 +781,22 @@ impl Transport for TmuxBackend {
737
781
  }
738
782
 
739
783
  fn kill_session(&self, session: &SessionName) -> Result<(), TransportError> {
740
- let argv = vec![
784
+ let argv = self.tmux_argv(&[
741
785
  "tmux".to_string(),
742
786
  "kill-session".to_string(),
743
787
  "-t".to_string(),
744
788
  session.as_str().to_string(),
745
- ];
789
+ ]);
746
790
  self.run_ok(&argv)
747
791
  }
748
792
 
749
793
  fn kill_window(&self, target: &Target) -> Result<(), TransportError> {
750
- let argv = vec![
794
+ let argv = self.tmux_argv(&[
751
795
  "tmux".to_string(),
752
796
  "kill-window".to_string(),
753
797
  "-t".to_string(),
754
798
  target_name(target),
755
- ];
799
+ ]);
756
800
  self.run_ok(&argv)
757
801
  }
758
802
 
@@ -771,6 +815,22 @@ impl Transport for TmuxBackend {
771
815
  }
772
816
  }
773
817
 
818
+ fn query_pane_pid(backend: &TmuxBackend, pane: &PaneId) -> Result<Option<u32>, TransportError> {
819
+ let argv = backend.tmux_argv(&[
820
+ "tmux".to_string(),
821
+ "display-message".to_string(),
822
+ "-p".to_string(),
823
+ "-t".to_string(),
824
+ pane.as_str().to_string(),
825
+ "#{pane_pid}".to_string(),
826
+ ]);
827
+ let output = backend.runner.run(&argv)?;
828
+ if !output.success {
829
+ return Ok(None);
830
+ }
831
+ Ok(parse_optional_u32(output.stdout.trim()))
832
+ }
833
+
774
834
  fn parse_pane_info_line(line: &str) -> Option<PaneInfo> {
775
835
  let fields = line.split('\t').collect::<Vec<_>>();
776
836
  if fields.len() < 11 {
@@ -786,7 +846,7 @@ fn parse_pane_info_line(line: &str) -> Option<PaneInfo> {
786
846
  current_command: non_empty(fields[6]).map(str::to_string),
787
847
  active: fields[7] == "1",
788
848
  current_path: non_empty(fields[8]).map(PathBuf::from),
789
- pane_pid: None,
849
+ pane_pid: fields.get(11).and_then(|raw| parse_optional_u32(raw)),
790
850
  leader_env: BTreeMap::new(),
791
851
  })
792
852
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
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.3.1",
24
- "@team-agent/cli-darwin-x64": "0.3.1",
25
- "@team-agent/cli-linux-x64": "0.3.1"
23
+ "@team-agent/cli-darwin-arm64": "0.3.2",
24
+ "@team-agent/cli-darwin-x64": "0.3.2",
25
+ "@team-agent/cli-linux-x64": "0.3.2"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",